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/CHANGELOG.md b/CHANGELOG.md index 85019b81f4..bf223708a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Roo Code Changelog +## [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) + +- Add MiniMax M2.1 and improve environment_details handling for Minimax thinking models (PR #10284 by @hannesrudolph) +- Add GLM-4.7 model with thinking mode support for Zai provider (PR #10282 by @hannesrudolph) +- Add experimental custom tool calling - define custom tools that integrate seamlessly with your AI workflow (PR #10083 by @cte) +- Deprecate XML tool protocol selection and force native tool format for new tasks (PR #10281 by @daniel-lxs) +- Fix: Emit tool_call_end events in OpenAI handler when streaming ends (#10275 by @torxeon, PR #10280 by @daniel-lxs) +- Fix: Emit tool_call_end events in BaseOpenAiCompatibleProvider (PR #10293 by @hannesrudolph) +- Fix: Disable strict mode for MCP tools to preserve optional parameters (PR #10220 by @daniel-lxs) +- Fix: Move array-specific properties into anyOf variant in normalizeToolSchema (PR #10276 by @daniel-lxs) +- Fix: Add CRLF line ending normalization to search_replace and search_and_replace tools (PR #10288 by @hannesrudolph) +- Fix: Add graceful fallback for model parsing in Chutes provider (PR #10279 by @hannesrudolph) +- Fix: Enable Requesty refresh models with credentials (PR #10273 by @daniel-lxs) +- Fix: Improve reasoning_details accumulation and serialization (PR #10285 by @hannesrudolph) +- Fix: Preserve reasoning_content in condense summary for DeepSeek-reasoner (PR #10292 by @hannesrudolph) +- Refactor Zai provider to merge environment_details into tool result instead of system message (PR #10289 by @hannesrudolph) +- Remove parallel_tool_calls parameter from litellm provider (PR #10274 by @roomote) +- Add Cloud Team page with comprehensive team management features (PR #10267 by @roomote) +- Add message log deduper utility for evals (PR #10286 by @hannesrudolph) + ## [3.36.16] - 2025-12-19 - Fix: Normalize tool schemas for VS Code LM API to resolve error 400 when using VS Code Language Model API providers (PR #10221 by @hannesrudolph) diff --git a/README.md b/README.md index 9e98cd8845..75f37762f9 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | -|
Custom Modes |
Checkpoints |
Todo Lists | +|
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..28fb4abfd5 100644 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ b/apps/web-evals/src/app/runs/new/new-run.tsx @@ -7,15 +7,26 @@ 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, @@ -77,14 +89,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 @@ -95,16 +105,15 @@ export function NewRun() { const router = useRouter() 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 +128,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,6 +142,7 @@ export function NewRun() { timeout: TIMEOUT_DEFAULT, iterations: ITERATIONS_DEFAULT, jobToken: "", + executionMethod: "vscode", }, }) @@ -146,38 +155,49 @@ export function NewRun() { const [suite, settings] = watch(["suite", "settings", "concurrency"]) - // 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 +209,57 @@ export function NewRun() { setValue("exercises", parsed) } } catch { - // Invalid JSON, ignore + // Invalid JSON, ignore. } } } }, [setValue]) - // 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 +270,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 +278,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 +287,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 +330,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 +337,20 @@ export function NewRun() { const onSubmit = useCallback( async (values: CreateRun) => { try { - // Validate jobToken for Roo Code Cloud provider if (provider === "roo" && !values.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 +363,19 @@ 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 } + runValues.executionMethod = executionMethod if (provider === "openrouter") { runValues.model = selection.model @@ -403,7 +418,6 @@ export function NewRun() { } } - // Navigate back to main evals UI router.push("/") } catch (e) { toast.error(e instanceof Error ? e.message : "An unknown error occurred.") @@ -411,6 +425,7 @@ export function NewRun() { }, [ provider, + executionMethod, modelSelections, configSelections, importedSettings, @@ -442,18 +457,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 +983,36 @@ export function NewRun() { + {/* Execution Method */} + ( + + Execution Method + { + const newExecutionMethod = value as ExecutionMethod + setExecutionMethod(newExecutionMethod) + setValue("executionMethod", newExecutionMethod) + }}> + + + + VSCode + + + + CLI + + + + + + )} + /> + + /** * 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/cloud/team/page.tsx b/apps/web-roo-code/src/app/cloud/team/page.tsx new file mode 100644 index 0000000000..7be79f74e2 --- /dev/null +++ b/apps/web-roo-code/src/app/cloud/team/page.tsx @@ -0,0 +1,297 @@ +import { + ArrowRight, + Users, + Settings, + BarChart3, + Lock, + Puzzle, + ShieldCheck, + DollarSign, + Share2, + LucideIcon, + Server, + ServerIcon, + RefreshCcw, +} from "lucide-react" +import type { Metadata } from "next" + +import { Button } from "@/components/ui" +import { AnimatedBackground } from "@/components/homepage" +import { SEO } from "@/lib/seo" +import { ogImageUrl } from "@/lib/og" +import { EXTERNAL_LINKS } from "@/lib/constants" + +const TITLE = "Roo Code Cloud Team Plan" +const DESCRIPTION = + "Scale your development with team collaboration features. Centralized billing, shared configuration, team-wide analytics, and unified GitHub and Slack integrations." +const OG_DESCRIPTION = "Team collaboration for AI-powered development" +const PATH = "/cloud/team" + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: `${SEO.url}${PATH}`, + }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: `${SEO.url}${PATH}`, + siteName: SEO.name, + images: [ + { + url: ogImageUrl(TITLE, OG_DESCRIPTION), + width: 1200, + height: 630, + alt: TITLE, + }, + ], + locale: SEO.locale, + type: "website", + }, + twitter: { + card: SEO.twitterCard, + title: TITLE, + description: DESCRIPTION, + images: [ogImageUrl(TITLE, OG_DESCRIPTION)], + }, + keywords: [ + ...SEO.keywords, + "team", + "collaboration", + "enterprise", + "organization", + "centralized billing", + "team management", + ], +} + +const keyBenefits = [ + { + title: "No Per-Seat Costs", + description: "Add unlimited team members without worrying about escalating per-seat charges.", + icon: Users, + }, + { + title: "Centralized Billing", + description: + "Single billing point for all team members using Cloud Agents and the Roo Code Cloud Provider. No more API key management.", + icon: DollarSign, + }, + { + title: "Unified Integrations", + description: + "Connect GitHub and Slack once for the entire team. No need for each member to set up individual integrations.", + icon: Settings, + }, + { + title: "Team-Wide Visibility", + description: "Access task history and usage analytics across your entire team with granular per-user filters.", + icon: BarChart3, + }, + { + title: "Configuration Enforcement", + description: + "Set policies for providers, models, and MCP servers to ensure your team follows organizational standards.", + icon: ShieldCheck, + }, + { + title: "Secure Environment Variables", + description: + "Centrally manage secrets, API keys, and environment variables for Cloud Agents in our encrypted secret store.", + icon: Lock, + }, +] + +interface Feature { + icon: LucideIcon + title: string + description: string +} + +const features: Feature[] = [ + { + icon: ShieldCheck, + title: "Configuration Enforcement", + description: + "Require team members to log in to the VS Code Extension so policies can be enforced via MDM distribution.", + }, + { + icon: Server, + title: "Provider Management", + description: + "Configure and manage the model providers your team can access for both the Extension and Cloud Agents, with API-key-free management.", + }, + { + icon: Puzzle, + title: "Centralized Integration", + description: + "Centralized GitHub and Slack connection for the entire team. Agents can review PRs, collaborate on your repositories and respond on your team channels.", + }, + { + icon: RefreshCcw, + title: "Extension Task Sync Config", + description: + "Require task syncing from VS Code Extension and control visibility settings for who can view each other's tasks.", + }, + { + icon: Share2, + title: "Task Sharing Controls", + description: "Enable per-task sharing with customizable audience controls and link expiration times.", + }, + { + icon: ServerIcon, + title: "MCP Server Controls", + description: "Control access to the Roo MCP Marketplace and what custom MCPs to make available to your team.", + }, +] + +export default function CloudTeamPage() { + return ( + <> + {/* Hero Section */} +

+ +
+
+

Roo Code Cloud Team

+

+ Built for AI-Forward Teams +

+

+ Empower your entire team with confidence with team-wide configuration, centralized billing, + analytics and more. No per-seat costs, no API key juggling. +

+
+ + +
+
+
+
+ + {/* Key Benefits Section */} +
+
+
+
+
+ +
+
+

Why Teams Choose Roo

+

+ Streamline collaboration and scale your development capacity with team-first features. +

+
+
+ +
+
    + {keyBenefits.map((benefit, index) => { + const Icon = benefit.icon + return ( +
  • + {Icon && ( +
    + +
    + )} +

    {benefit.title}

    +
    + {benefit.description} +
    +
  • + ) + })} +
+
+
+
+ + {/* Features Grid */} +
+
+
+
+
+
+

Complete Team Management

+

+ Access all capabilities from your Organization Settings. Everything you need to manage your + team in one place. +

+
+
+ {features.map((feature, index) => { + const Icon = feature.icon + return ( +
+ +

{feature.title}

+

+ {feature.description} +

+
+ ) + })} +
+
+
+ + {/* CTA Section */} +
+
+
+

+ Ready to scale your team's development? +

+

+ Start your free 14-day trial today. Got questions? Get in touch. +

+
+ + +
+
+
+
+ + ) +} diff --git a/apps/web-roo-code/src/app/pricing/page.tsx b/apps/web-roo-code/src/app/pricing/page.tsx index 2e3c64fc73..8a79470ea1 100644 --- a/apps/web-roo-code/src/app/pricing/page.tsx +++ b/apps/web-roo-code/src/app/pricing/page.tsx @@ -71,6 +71,7 @@ interface PricingTier { text: string href?: string } + learnMoreLink?: string } const pricingTiers: PricingTier[] = [ @@ -123,6 +124,7 @@ const pricingTiers: PricingTier[] = [ text: "Sign up", href: EXTERNAL_LINKS.CLOUD_APP_SIGNUP + "?redirect_url=/billing", }, + learnMoreLink: "/cloud/team", }, ] @@ -164,11 +166,11 @@ export default function PricingPage() { -
+

{tier.featuresIntro} 

-
    +
      {tier.features.map((feature) => (
    • @@ -176,6 +178,15 @@ export default function PricingPage() {
    • ))}
    + {tier.learnMoreLink && ( +
    + + Learn more → + +
    + )}

{tier.trial}

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/chromes/nav-bar.tsx b/apps/web-roo-code/src/components/chromes/nav-bar.tsx index fa51f081ce..046e3e0bd8 100644 --- a/apps/web-roo-code/src/components/chromes/nav-bar.tsx +++ b/apps/web-roo-code/src/components/chromes/nav-bar.tsx @@ -70,11 +70,6 @@ export function NavBar({ stars, downloads }: NavBarProps) { {/* Dropdown Menu */}
    - - FAQ - 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/apps/web-roo-code/src/lib/constants.ts b/apps/web-roo-code/src/lib/constants.ts index 65e70d8f6c..fe0137661a 100644 --- a/apps/web-roo-code/src/lib/constants.ts +++ b/apps/web-roo-code/src/lib/constants.ts @@ -28,6 +28,7 @@ export const EXTERNAL_LINKS = { CLOUD_APP_SIGNUP: "https://app.roocode.com/sign-up", CLOUD_APP_SIGNUP_HOME: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup", CLOUD_APP_SIGNUP_PRO: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup", + SUPPORT: "mailto:support@roocode.com", } export const INTERNAL_LINKS = { diff --git a/locales/ca/README.md b/locales/ca/README.md index b8feb8a99a..0c09d8c661 100644 --- a/locales/ca/README.md +++ b/locales/ca/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..526d601e70 100644 --- a/locales/de/README.md +++ b/locales/de/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..9e378b7fd7 100644 --- a/locales/es/README.md +++ b/locales/es/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..5197e76f0e 100644 --- a/locales/fr/README.md +++ b/locales/fr/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..8d12b68994 100644 --- a/locales/hi/README.md +++ b/locales/hi/README.md @@ -69,7 +69,7 @@ | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    रू कोड इंस्टॉल करना |
    प्रोफाइल कॉन्फ़िगर करना |
    कोडबेस इंडेक्सिंग | -|
    कस्टम मोड |
    चेकपॉइंट्स |
    टू-डू लिस्ट | +|
    कस्टम मोड |
    चेकपॉइंट्स |
    संदर्भ प्रबंधन |

    diff --git a/locales/id/README.md b/locales/id/README.md index 9c46a5ca2e..657b1ab750 100644 --- a/locales/id/README.md +++ b/locales/id/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..9bd5ce9e81 100644 --- a/locales/it/README.md +++ b/locales/it/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..3b7a7a6e6e 100644 --- a/locales/ja/README.md +++ b/locales/ja/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..a53a1b965f 100644 --- a/locales/ko/README.md +++ b/locales/ko/README.md @@ -69,7 +69,7 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다. | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Roo Code 설치하기 |
    프로필 구성하기 |
    코드베이스 인덱싱 | -|
    사용자 지정 모드 |
    체크포인트 |
    할 일 목록 | +|
    사용자 지정 모드 |
    체크포인트 |
    컨텍스트 관리 |

    diff --git a/locales/nl/README.md b/locales/nl/README.md index 983318aa81..fa5454b9c5 100644 --- a/locales/nl/README.md +++ b/locales/nl/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..b8553a08c7 100644 --- a/locales/pl/README.md +++ b/locales/pl/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..3b128b0fd3 100644 --- a/locales/pt-BR/README.md +++ b/locales/pt-BR/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..9abf4ae511 100644 --- a/locales/ru/README.md +++ b/locales/ru/README.md @@ -69,7 +69,7 @@ Roo Code адаптируется к вашему стилю работы, а н | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    Установка Roo Code |
    Настройка профилей |
    Индексация кодовой базы | -|
    Пользовательские режимы |
    Контрольные точки |
    Списки дел | +|
    Пользовательские режимы |
    Контрольные точки |
    Управление Контекстом |

    diff --git a/locales/tr/README.md b/locales/tr/README.md index c095677851..ac5a788407 100644 --- a/locales/tr/README.md +++ b/locales/tr/README.md @@ -69,7 +69,7 @@ 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 | +|
    Özel Modlar |
    Kontrol Noktaları |
    Bağlam Yönetimi |

    diff --git a/locales/vi/README.md b/locales/vi/README.md index 5b8100eb2b..bedaa3c26a 100644 --- a/locales/vi/README.md +++ b/locales/vi/README.md @@ -69,7 +69,7 @@ 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 | +|
    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..a21e147f96 100644 --- a/locales/zh-CN/README.md +++ b/locales/zh-CN/README.md @@ -69,7 +69,7 @@ Roo Code 适应您的工作方式,而不是相反: | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
    安装 Roo Code |
    配置个人资料 |
    代码库索引 | -|
    自定义模式 |
    检查点 |
    待办事项列表 | +|
    自定义模式 |
    检查点 |
    上下文管理 |

    diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md index 05d46aca68..35febb2348 100644 --- a/locales/zh-TW/README.md +++ b/locales/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/build/src/esbuild.ts b/packages/build/src/esbuild.ts index 3b793c2cc9..952e823eec 100644 --- a/packages/build/src/esbuild.ts +++ b/packages/build/src/esbuild.ts @@ -158,6 +158,58 @@ export function copyWasms(srcDir: string, distDir: string): void { }) console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`) + + // Copy esbuild-wasm files for custom tool transpilation (cross-platform). + copyEsbuildWasmFiles(nodeModulesDir, distDir) +} + +/** + * Copy esbuild-wasm files to the dist/bin directory. + * + * This function copies the esbuild-wasm CLI and WASM binary, which provides + * a cross-platform esbuild implementation that works on all platforms. + * + * Files copied: + * - bin/esbuild (Node.js CLI script) + * - esbuild.wasm (WASM binary) + * - wasm_exec_node.js (Go WASM runtime for Node.js) + * - wasm_exec.js (Go WASM runtime dependency) + */ +function copyEsbuildWasmFiles(nodeModulesDir: string, distDir: string): void { + const esbuildWasmDir = path.join(nodeModulesDir, "esbuild-wasm") + + if (!fs.existsSync(esbuildWasmDir)) { + throw new Error(`Directory does not exist: ${esbuildWasmDir}`) + } + + // Create bin directory in dist. + const binDir = path.join(distDir, "bin") + fs.mkdirSync(binDir, { recursive: true }) + + // Files to copy - the esbuild CLI script expects wasm_exec_node.js and esbuild.wasm + // to be one directory level up from the bin directory (i.e., in distDir directly). + // wasm_exec_node.js requires wasm_exec.js, so we need to copy that too. + const filesToCopy = [ + { src: path.join(esbuildWasmDir, "bin", "esbuild"), dest: path.join(binDir, "esbuild") }, + { src: path.join(esbuildWasmDir, "esbuild.wasm"), dest: path.join(distDir, "esbuild.wasm") }, + { src: path.join(esbuildWasmDir, "wasm_exec_node.js"), dest: path.join(distDir, "wasm_exec_node.js") }, + { src: path.join(esbuildWasmDir, "wasm_exec.js"), dest: path.join(distDir, "wasm_exec.js") }, + ] + + for (const { src, dest } of filesToCopy) { + fs.copyFileSync(src, dest) + + // Make CLI executable. + if (src.endsWith("esbuild")) { + try { + fs.chmodSync(dest, 0o755) + } catch { + // Ignore chmod errors on Windows. + } + } + } + + console.log(`[copyWasms] Copied ${filesToCopy.length} esbuild-wasm files to ${distDir}`) } export function copyLocales(srcDir: string, distDir: string): void { 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/eslint.config.mjs b/packages/core/eslint.config.mjs new file mode 100644 index 0000000000..694bf73664 --- /dev/null +++ b/packages/core/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/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000..5151d88d95 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,26 @@ +{ + "name": "@roo-code/core", + "description": "Platform agnostic core functionality for Roo Code.", + "version": "0.0.0", + "type": "module", + "exports": "./src/index.ts", + "scripts": { + "lint": "eslint src --ext=ts --max-warnings=0", + "check-types": "tsc --noEmit", + "test": "vitest run", + "clean": "rimraf .turbo" + }, + "dependencies": { + "@roo-code/types": "workspace:^", + "esbuild": "^0.25.0", + "execa": "^9.5.2", + "openai": "^5.12.2", + "zod": "^3.25.61" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "^24.1.0", + "vitest": "^3.2.3" + } +} diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap new file mode 100644 index 0000000000..d4ca0b1683 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/__snapshots__/format-native.spec.ts.snap @@ -0,0 +1,231 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Native Protocol snapshots > should generate correct native definition for cached tool 1`] = ` +{ + "function": { + "description": "Cached tool", + "name": "cached", + "parameters": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", +} +`; + +exports[`Native Protocol snapshots > should generate correct native definition for legacy tool (using args) 1`] = ` +{ + "function": { + "description": "Legacy tool using args", + "name": "legacy", + "parameters": { + "additionalProperties": false, + "properties": { + "input": { + "description": "The input string", + "type": "string", + }, + }, + "required": [ + "input", + ], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", +} +`; + +exports[`Native Protocol snapshots > should generate correct native definition for mixed export tool 1`] = ` +{ + "function": { + "description": "Valid", + "name": "mixed_validTool", + "parameters": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", +} +`; + +exports[`Native Protocol snapshots > should generate correct native definition for simple tool 1`] = ` +{ + "function": { + "description": "Simple tool", + "name": "simple", + "parameters": { + "additionalProperties": false, + "properties": { + "value": { + "description": "The input value", + "type": "string", + }, + }, + "required": [ + "value", + ], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", +} +`; + +exports[`Native Protocol snapshots > should generate correct native definitions for all fixtures combined 1`] = ` +[ + { + "function": { + "description": "Simple tool", + "name": "simple", + "parameters": { + "additionalProperties": false, + "properties": { + "value": { + "description": "The input value", + "type": "string", + }, + }, + "required": [ + "value", + ], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", + }, + { + "function": { + "description": "Cached tool", + "name": "cached", + "parameters": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", + }, + { + "function": { + "description": "Legacy tool using args", + "name": "legacy", + "parameters": { + "additionalProperties": false, + "properties": { + "input": { + "description": "The input string", + "type": "string", + }, + }, + "required": [ + "input", + ], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", + }, + { + "function": { + "description": "Tool A", + "name": "multi_toolA", + "parameters": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", + }, + { + "function": { + "description": "Tool B", + "name": "multi_toolB", + "parameters": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", + }, + { + "function": { + "description": "Valid", + "name": "mixed_validTool", + "parameters": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", + }, +] +`; + +exports[`Native Protocol snapshots > should generate correct native definitions for multi export tools 1`] = ` +[ + { + "function": { + "description": "Tool A", + "name": "multi_toolA", + "parameters": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", + }, + { + "function": { + "description": "Tool B", + "name": "multi_toolB", + "parameters": { + "additionalProperties": false, + "properties": {}, + "required": [], + "type": "object", + }, + "source": undefined, + "strict": true, + }, + "type": "function", + }, +] +`; diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap new file mode 100644 index 0000000000..b4503fa925 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/__snapshots__/format-xml.spec.ts.snap @@ -0,0 +1,129 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`XML Protocol snapshots > should generate correct XML description for all fixtures combined 1`] = ` +"# Custom Tools + +The following custom tools are available for this mode. Use them in the same way as built-in tools. + +## simple +Description: Simple tool +Parameters: +- value: (required) The input value (type: string) +Usage: + +value value here + + +## cached +Description: Cached tool +Parameters: +Usage: + + + +## legacy +Description: Legacy tool using args +Parameters: +- input: (required) The input string (type: string) +Usage: + +input value here + + +## multi_toolA +Description: Tool A +Parameters: +Usage: + + + +## multi_toolB +Description: Tool B +Parameters: +Usage: + + + +## mixed_validTool +Description: Valid +Parameters: +Usage: + +" +`; + +exports[`XML Protocol snapshots > should generate correct XML description for cached tool 1`] = ` +"# Custom Tools + +The following custom tools are available for this mode. Use them in the same way as built-in tools. + +## cached +Description: Cached tool +Parameters: +Usage: + +" +`; + +exports[`XML Protocol snapshots > should generate correct XML description for legacy tool (using args) 1`] = ` +"# Custom Tools + +The following custom tools are available for this mode. Use them in the same way as built-in tools. + +## legacy +Description: Legacy tool using args +Parameters: +- input: (required) The input string (type: string) +Usage: + +input value here +" +`; + +exports[`XML Protocol snapshots > should generate correct XML description for mixed export tool 1`] = ` +"# Custom Tools + +The following custom tools are available for this mode. Use them in the same way as built-in tools. + +## mixed_validTool +Description: Valid +Parameters: +Usage: + +" +`; + +exports[`XML Protocol snapshots > should generate correct XML description for multi export tools 1`] = ` +"# Custom Tools + +The following custom tools are available for this mode. Use them in the same way as built-in tools. + +## multi_toolA +Description: Tool A +Parameters: +Usage: + + + +## multi_toolB +Description: Tool B +Parameters: +Usage: + +" +`; + +exports[`XML Protocol snapshots > should generate correct XML description for simple tool 1`] = ` +"# Custom Tools + +The following custom tools are available for this mode. Use them in the same way as built-in tools. + +## simple +Description: Simple tool +Parameters: +- value: (required) The input value (type: string) +Usage: + +value value here +" +`; diff --git a/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap b/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap new file mode 100644 index 0000000000..6da6a93e9c --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/__snapshots__/serialize.spec.ts.snap @@ -0,0 +1,146 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Serialization snapshots > should correctly serialize all fixtures 1`] = ` +[ + { + "description": "Simple tool", + "name": "simple", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "value": { + "description": "The input value", + "type": "string", + }, + }, + "required": [ + "value", + ], + "type": "object", + }, + "source": undefined, + }, + { + "description": "Cached tool", + "name": "cached", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "type": "object", + }, + "source": undefined, + }, + { + "description": "Legacy tool using args", + "name": "legacy", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "input": { + "description": "The input string", + "type": "string", + }, + }, + "required": [ + "input", + ], + "type": "object", + }, + "source": undefined, + }, + { + "description": "Tool A", + "name": "multi_toolA", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "type": "object", + }, + "source": undefined, + }, + { + "description": "Tool B", + "name": "multi_toolB", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "type": "object", + }, + "source": undefined, + }, + { + "description": "Valid", + "name": "mixed_validTool", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "type": "object", + }, + "source": undefined, + }, +] +`; + +exports[`Serialization snapshots > should correctly serialize cached tool 1`] = ` +{ + "description": "Cached tool", + "name": "cached", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": {}, + "type": "object", + }, + "source": undefined, +} +`; + +exports[`Serialization snapshots > should correctly serialize legacy tool (using args) 1`] = ` +{ + "description": "Legacy tool using args", + "name": "legacy", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "input": { + "description": "The input string", + "type": "string", + }, + }, + "required": [ + "input", + ], + "type": "object", + }, + "source": undefined, +} +`; + +exports[`Serialization snapshots > should correctly serialize simple tool 1`] = ` +{ + "description": "Simple tool", + "name": "simple", + "parameters": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "value": { + "description": "The input value", + "type": "string", + }, + }, + "required": [ + "value", + ], + "type": "object", + }, + "source": undefined, +} +`; diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts new file mode 100644 index 0000000000..967ae2e8df --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts @@ -0,0 +1,381 @@ +// pnpm --filter @roo-code/core test src/custom-tools/__tests__/custom-tool-registry.spec.ts + +import path from "path" +import { fileURLToPath } from "url" + +import { type CustomToolDefinition, parametersSchema as z } from "@roo-code/types" + +import { CustomToolRegistry } from "../custom-tool-registry.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const TEST_FIXTURES_DIR = path.join(__dirname, "fixtures") +const TEST_FIXTURES_OVERRIDE_DIR = path.join(__dirname, "fixtures-override") + +describe("CustomToolRegistry", () => { + let registry: CustomToolRegistry + + beforeEach(() => { + registry = new CustomToolRegistry() + }) + + describe("validation", () => { + it("should accept a valid tool definition", () => { + const validTool = { + name: "valid_tool", + description: "A valid tool", + parameters: z.object({ name: z.string() }), + execute: async () => "result", + } + + expect(() => registry.register(validTool)).not.toThrow() + expect(registry.has("valid_tool")).toBe(true) + }) + + it("should reject empty description", () => { + const invalidTool = { + name: "invalid_tool", + description: "", + parameters: z.object({}), + execute: async () => "result", + } + + expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/) + }) + + it("should reject non-Zod parameters", () => { + const invalidTool = { + name: "bad_params_tool", + description: "Tool with bad params", + parameters: { foo: "bar" }, + execute: async () => "result", + } + + expect(() => registry.register(invalidTool as unknown as CustomToolDefinition)).toThrow( + /Invalid tool definition/, + ) + }) + + it("should allow missing parameters", () => { + const toolWithoutParams = { + name: "no_params_tool", + description: "Tool without parameters", + execute: async () => "result", + } + + expect(() => registry.register(toolWithoutParams)).not.toThrow() + expect(registry.has("no_params_tool")).toBe(true) + }) + + it("should reject empty name", () => { + const invalidTool = { + name: "", + description: "Tool with empty name", + execute: async () => "result", + } + + expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/) + }) + + it("should reject missing name", () => { + const invalidTool = { + description: "Tool without name", + execute: async () => "result", + } + + expect(() => registry.register(invalidTool as unknown as CustomToolDefinition)).toThrow( + /Invalid tool definition/, + ) + }) + }) + + describe("register", () => { + it("should register a valid tool", () => { + const tool: CustomToolDefinition = { + name: "test_tool", + description: "Test tool", + parameters: z.object({ input: z.string() }), + execute: async (args: { input: string }) => `Processed: ${args.input}`, + } + + registry.register(tool) + + expect(registry.has("test_tool")).toBe(true) + expect(registry.size).toBe(1) + }) + + it("should throw for invalid tool definition", () => { + const invalidTool = { + name: "bad_tool", + description: "", + execute: async () => "result", + } + + expect(() => registry.register(invalidTool as CustomToolDefinition)).toThrow(/Invalid tool definition/) + }) + + it("should overwrite existing tool with same id", () => { + const tool1: CustomToolDefinition = { + name: "tool", + description: "First version", + execute: async () => "v1", + } + + const tool2: CustomToolDefinition = { + name: "tool", + description: "Second version", + execute: async () => "v2", + } + + registry.register(tool1) + registry.register(tool2) + + expect(registry.size).toBe(1) + expect(registry.get("tool")?.description).toBe("Second version") + }) + }) + + describe("unregister", () => { + it("should remove a registered tool", () => { + registry.register({ + name: "tool", + description: "Test", + execute: async () => "result", + }) + + const result = registry.unregister("tool") + + expect(result).toBe(true) + expect(registry.has("tool")).toBe(false) + }) + + it("should return false for non-existent tool", () => { + const result = registry.unregister("nonexistent") + expect(result).toBe(false) + }) + }) + + describe("get", () => { + it("should return registered tool", () => { + registry.register({ + name: "my_tool", + description: "My tool", + execute: async () => "result", + }) + + const tool = registry.get("my_tool") + + expect(tool).toBeDefined() + expect(tool?.name).toBe("my_tool") + expect(tool?.description).toBe("My tool") + }) + + it("should return undefined for non-existent tool", () => { + expect(registry.get("nonexistent")).toBeUndefined() + }) + }) + + describe("list", () => { + it("should return all tool IDs", () => { + registry.register({ name: "tool_a", description: "A", execute: async () => "a" }) + registry.register({ name: "tool_b", description: "B", execute: async () => "b" }) + registry.register({ name: "tool_c", description: "C", execute: async () => "c" }) + + const ids = registry.list() + + expect(ids).toHaveLength(3) + expect(ids).toContain("tool_a") + expect(ids).toContain("tool_b") + expect(ids).toContain("tool_c") + }) + + it("should return empty array when no tools registered", () => { + expect(registry.list()).toEqual([]) + }) + }) + + describe("getAll", () => { + it("should return all tools as array", () => { + registry.register({ name: "tool1", description: "Tool 1", execute: async () => "1" }) + registry.register({ name: "tool2", description: "Tool 2", execute: async () => "2" }) + + const all = registry.getAll() + + expect(all).toHaveLength(2) + expect(all.find((t) => t.name === "tool1")?.description).toBe("Tool 1") + expect(all.find((t) => t.name === "tool2")?.description).toBe("Tool 2") + }) + }) + + describe("clear", () => { + it("should remove all registered tools", () => { + registry.register({ name: "tool1", description: "1", execute: async () => "1" }) + registry.register({ name: "tool2", description: "2", execute: async () => "2" }) + + expect(registry.size).toBe(2) + + registry.clear() + + expect(registry.size).toBe(0) + expect(registry.list()).toEqual([]) + }) + }) + + describe.sequential("loadFromDirectory", () => { + it("should load tools from TypeScript files", async () => { + const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR) + + expect(result.loaded).toContain("simple") + expect(registry.has("simple")).toBe(true) + }, 60000) + + it("should handle named exports", async () => { + const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR) + + expect(result.loaded).toContain("multi_toolA") + expect(result.loaded).toContain("multi_toolB") + }, 30000) + + it("should report validation failures", async () => { + const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR) + + const invalidFailure = result.failed.find((f) => f.file === "invalid.ts") + expect(invalidFailure).toBeDefined() + expect(invalidFailure?.error).toContain("Invalid tool definition") + }, 30000) + + it("should return empty results for non-existent directory", async () => { + const result = await registry.loadFromDirectory("/nonexistent/path") + + expect(result.loaded).toHaveLength(0) + expect(result.failed).toHaveLength(0) + }) + + it("should skip non-tool exports silently", async () => { + const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR) + + expect(result.loaded).toContain("mixed_validTool") + // The non-tool exports should not appear in loaded or failed. + expect(result.loaded).not.toContain("mixed_someString") + expect(result.loaded).not.toContain("mixed_someNumber") + expect(result.loaded).not.toContain("mixed_someObject") + }, 30000) + + it("should support args as alias for parameters", async () => { + const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR) + + expect(result.loaded).toContain("legacy") + + const tool = registry.get("legacy") + expect(tool?.parameters).toBeDefined() + }, 30000) + }) + + describe.sequential("clearCache", () => { + it("should clear the TypeScript compilation cache", async () => { + await registry.loadFromDirectory(TEST_FIXTURES_DIR) + registry.clearCache() + + // Should be able to load again without issues. + registry.clear() + const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR) + + expect(result.loaded).toContain("cached") + }, 30000) + }) + + describe.sequential("loadFromDirectories", () => { + it("should load tools from multiple directories", async () => { + const result = await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR]) + + // Should load tools from both directories. + expect(result.loaded).toContain("simple") // From both directories (override wins). + expect(result.loaded).toContain("unique_override") // Only in override directory. + expect(result.loaded).toContain("multi_toolA") // Only in fixtures directory. + }, 60000) + + it("should allow later directories to override earlier ones", async () => { + await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR]) + + // The simple tool should have the overridden description. + const simpleTool = registry.get("simple") + expect(simpleTool).toBeDefined() + expect(simpleTool?.description).toBe("Simple tool - OVERRIDDEN") + }, 60000) + + it("should preserve order: first directory loaded first, second overrides", async () => { + // Load in reverse order: override first, then fixtures. + await registry.loadFromDirectories([TEST_FIXTURES_OVERRIDE_DIR, TEST_FIXTURES_DIR]) + + // Now the original fixtures directory should win. + const simpleTool = registry.get("simple") + expect(simpleTool).toBeDefined() + expect(simpleTool?.description).toBe("Simple tool") // Original wins when loaded second. + }, 60000) + + it("should handle non-existent directories in the array", async () => { + const result = await registry.loadFromDirectories([ + "/nonexistent/path", + TEST_FIXTURES_DIR, + "/another/nonexistent", + ]) + + // Should still load from the existing directory. + expect(result.loaded).toContain("simple") + expect(result.failed).toHaveLength(1) // Only the invalid.ts from fixtures. + }, 60000) + + it("should handle empty array", async () => { + const result = await registry.loadFromDirectories([]) + + expect(result.loaded).toHaveLength(0) + expect(result.failed).toHaveLength(0) + }) + + it("should combine results from all directories", async () => { + const result = await registry.loadFromDirectories([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR]) + + // Loaded should include tools from both (with duplicates since simple is loaded twice). + // The "simple" tool is loaded from both directories. + const simpleCount = result.loaded.filter((name) => name === "simple").length + expect(simpleCount).toBe(2) // Listed twice in loaded results. + }, 60000) + }) + + describe.sequential("loadFromDirectoriesIfStale", () => { + it("should load tools from multiple directories when stale", async () => { + const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR]) + + expect(result.loaded).toContain("simple") + expect(result.loaded).toContain("unique_override") + }, 60000) + + it("should not reload if directories are not stale", async () => { + // First load. + await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR]) + + // Clear tools but keep staleness tracking. + // (firstLoadSize is captured to document that tools were loaded, then cleared). + const _firstLoadSize = registry.size + registry.clear() + + // Second load - should return cached tool names without reloading. + const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR]) + + // Registry was cleared, not stale so no reload. + expect(result.loaded).toEqual([]) + }, 30000) + + it("should handle mixed stale and non-stale directories", async () => { + // Load from fixtures first. + await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR]) + + // Load from both - fixtures is not stale, override is new (stale). + const result = await registry.loadFromDirectoriesIfStale([TEST_FIXTURES_DIR, TEST_FIXTURES_OVERRIDE_DIR]) + + // Override directory tools should be loaded (it's stale/new). + expect(result.loaded).toContain("simple") // From override (stale). + expect(result.loaded).toContain("unique_override") // From override (stale). + }, 60000) + }) +}) diff --git a/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts new file mode 100644 index 0000000000..affb1a3734 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts @@ -0,0 +1,253 @@ +import fs from "fs" +import os from "os" +import path from "path" + +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", () => { + const scriptPath = getEsbuildScriptPath() + + // Should find the script. + expect(typeof scriptPath).toBe("string") + expect(scriptPath.length).toBeGreaterThan(0) + + // The script should exist. + expect(fs.existsSync(scriptPath)).toBe(true) + + // Should be the esbuild script (not a binary). + expect(scriptPath).toMatch(/esbuild$/) + }) + + it("should prefer production path when extensionPath is provided and script exists", () => { + // Create a temporary directory with a fake script. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-")) + const binDir = path.join(tempDir, "dist", "bin") + fs.mkdirSync(binDir, { recursive: true }) + + const fakeScriptPath = path.join(binDir, "esbuild") + fs.writeFileSync(fakeScriptPath, "#!/usr/bin/env node\nconsole.log('fake esbuild')") + + try { + const result = getEsbuildScriptPath(tempDir) + expect(result).toBe(fakeScriptPath) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it("should fall back to node_modules when production script does not exist", () => { + // Pass a non-existent extension path. + const result = getEsbuildScriptPath("/nonexistent/extension/path") + + // Should fall back to development path. + expect(typeof result).toBe("string") + expect(result.length).toBeGreaterThan(0) + expect(fs.existsSync(result)).toBe(true) + }) +}) + +describe("runEsbuild", () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-")) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it("should compile a TypeScript file to ESM", async () => { + // Create a simple TypeScript file. + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync( + inputFile, + ` + export const greeting = "Hello, World!" + export function add(a: number, b: number): number { + return a + b + } + `, + ) + + await runEsbuild({ + entryPoint: inputFile, + outfile: outputFile, + format: "esm", + platform: "node", + target: "node18", + bundle: true, + }) + + // Verify output file exists. + expect(fs.existsSync(outputFile)).toBe(true) + + // Verify output content is valid JavaScript. + const outputContent = fs.readFileSync(outputFile, "utf-8") + expect(outputContent).toContain("Hello, World!") + expect(outputContent).toContain("add") + }, 30000) + + it("should generate inline source maps when specified", 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", sourcemap: "inline" }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + expect(outputContent).toContain("sourceMappingURL=data:") + }, 30000) + + it("should throw an error for invalid TypeScript", async () => { + const inputFile = path.join(tempDir, "invalid.ts") + const outputFile = path.join(tempDir, "output.mjs") + + // Write syntactically invalid TypeScript. + fs.writeFileSync(inputFile, `export const value = {{{ invalid syntax`) + + await expect(runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm" })).rejects.toThrow() + }, 30000) + + it("should throw an error for non-existent file", async () => { + const nonExistentFile = path.join(tempDir, "does-not-exist.ts") + const outputFile = path.join(tempDir, "output.mjs") + + await expect(runEsbuild({ entryPoint: nonExistentFile, outfile: outputFile, format: "esm" })).rejects.toThrow() + }, 30000) + + it("should bundle dependencies when bundle option is true", async () => { + // Create two files where one imports the other. + const libFile = path.join(tempDir, "lib.ts") + const mainFile = path.join(tempDir, "main.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(libFile, `export const PI = 3.14159`) + fs.writeFileSync( + mainFile, + ` + import { PI } from "./lib.js" + export const circumference = (r: number) => 2 * PI * r + `, + ) + + await runEsbuild({ entryPoint: mainFile, outfile: outputFile, format: "esm", bundle: true }) + + const outputContent = fs.readFileSync(outputFile, "utf-8") + // The PI constant should be bundled inline. + expect(outputContent).toContain("3.14159") + }, 30000) + + it("should respect platform option", async () => { + const inputFile = path.join(tempDir, "input.ts") + const outputFile = path.join(tempDir, "output.mjs") + + fs.writeFileSync(inputFile, `export const value = process.env.NODE_ENV`) + + await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", platform: "node" }) + + // 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/__tests__/fixtures-override/simple.ts b/packages/core/src/custom-tools/__tests__/fixtures-override/simple.ts new file mode 100644 index 0000000000..9235f4e333 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/fixtures-override/simple.ts @@ -0,0 +1,11 @@ +import { parametersSchema, defineCustomTool } from "@roo-code/types" + +// This tool has the same name as the one in fixtures/ to test override behavior. +export default defineCustomTool({ + name: "simple", + description: "Simple tool - OVERRIDDEN", + parameters: parametersSchema.object({ value: parametersSchema.string().describe("The input value") }), + async execute(args: { value: string }) { + return "Overridden Result: " + args.value + }, +}) diff --git a/packages/core/src/custom-tools/__tests__/fixtures-override/unique.ts b/packages/core/src/custom-tools/__tests__/fixtures-override/unique.ts new file mode 100644 index 0000000000..867d244649 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/fixtures-override/unique.ts @@ -0,0 +1,11 @@ +import { parametersSchema, defineCustomTool } from "@roo-code/types" + +// This tool only exists in fixtures-override/ to test combined loading. +export default defineCustomTool({ + name: "unique_override", + description: "A unique tool only in override directory", + parameters: parametersSchema.object({ input: parametersSchema.string().describe("The input") }), + async execute(args: { input: string }) { + return "Unique: " + args.input + }, +}) diff --git a/packages/core/src/custom-tools/__tests__/fixtures/cached.ts b/packages/core/src/custom-tools/__tests__/fixtures/cached.ts new file mode 100644 index 0000000000..a553d2be6c --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/fixtures/cached.ts @@ -0,0 +1,10 @@ +import { parametersSchema, defineCustomTool } from "@roo-code/types" + +export default defineCustomTool({ + name: "cached", + description: "Cached tool", + parameters: parametersSchema.object({}), + async execute() { + return "cached" + }, +}) diff --git a/packages/core/src/custom-tools/__tests__/fixtures/invalid.ts b/packages/core/src/custom-tools/__tests__/fixtures/invalid.ts new file mode 100644 index 0000000000..35c6c37e44 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/fixtures/invalid.ts @@ -0,0 +1,4 @@ +export default { + description: "", // Invalid: empty description. + execute: async () => "result", +} diff --git a/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts b/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts new file mode 100644 index 0000000000..32b2004e73 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/fixtures/legacy.ts @@ -0,0 +1,10 @@ +import { parametersSchema, defineCustomTool } from "@roo-code/types" + +export default defineCustomTool({ + name: "legacy", + description: "Legacy tool using args", + parameters: parametersSchema.object({ input: parametersSchema.string().describe("The input string") }), + async execute(args: { input: string }) { + return args.input + }, +}) diff --git a/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts b/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts new file mode 100644 index 0000000000..8ab95080d1 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/fixtures/mixed.ts @@ -0,0 +1,16 @@ +import { parametersSchema, defineCustomTool } from "@roo-code/types" + +// This is a valid tool. +export const validTool = defineCustomTool({ + name: "mixed_validTool", + description: "Valid", + parameters: parametersSchema.object({}), + async execute() { + return "valid" + }, +}) + +// These should be silently skipped. +export const someString = "not a tool" +export const someNumber = 42 +export const someObject = { foo: "bar" } diff --git a/packages/core/src/custom-tools/__tests__/fixtures/multi.ts b/packages/core/src/custom-tools/__tests__/fixtures/multi.ts new file mode 100644 index 0000000000..229e20305f --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/fixtures/multi.ts @@ -0,0 +1,19 @@ +import { parametersSchema, defineCustomTool } from "@roo-code/types" + +export const toolA = defineCustomTool({ + name: "multi_toolA", + description: "Tool A", + parameters: parametersSchema.object({}), + async execute() { + return "A" + }, +}) + +export const toolB = defineCustomTool({ + name: "multi_toolB", + description: "Tool B", + parameters: parametersSchema.object({}), + async execute() { + return "B" + }, +}) diff --git a/packages/core/src/custom-tools/__tests__/fixtures/simple.ts b/packages/core/src/custom-tools/__tests__/fixtures/simple.ts new file mode 100644 index 0000000000..d2c6ae600b --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/fixtures/simple.ts @@ -0,0 +1,10 @@ +import { parametersSchema, defineCustomTool } from "@roo-code/types" + +export default defineCustomTool({ + name: "simple", + description: "Simple tool", + parameters: parametersSchema.object({ value: parametersSchema.string().describe("The input value") }), + async execute(args: { value: string }) { + return "Result: " + args.value + }, +}) diff --git a/packages/core/src/custom-tools/__tests__/format-native.spec.ts b/packages/core/src/custom-tools/__tests__/format-native.spec.ts new file mode 100644 index 0000000000..33b909623e --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/format-native.spec.ts @@ -0,0 +1,250 @@ +// pnpm --filter @roo-code/core test src/custom-tools/__tests__/format-native.spec.ts + +import { type SerializedCustomToolDefinition, parametersSchema as z, defineCustomTool } from "@roo-code/types" + +import { serializeCustomTool, serializeCustomTools } from "../serialize.js" +import { formatNative } from "../format-native.js" + +import simpleTool from "./fixtures/simple.js" +import cachedTool from "./fixtures/cached.js" +import legacyTool from "./fixtures/legacy.js" +import { toolA, toolB } from "./fixtures/multi.js" +import { validTool as mixedValidTool } from "./fixtures/mixed.js" + +const fixtureTools = { + simple: simpleTool, + cached: cachedTool, + legacy: legacyTool, + multi_toolA: toolA, + multi_toolB: toolB, + mixed_validTool: mixedValidTool, +} + +describe("formatNative", () => { + it("should convert a tool without args", () => { + const tool = defineCustomTool({ + name: "simple_tool", + description: "A simple tool", + async execute() { + return "done" + }, + }) + + const serialized = serializeCustomTool(tool) + const result = formatNative(serialized) + + expect(result).toEqual({ + type: "function", + function: { + name: "simple_tool", + description: "A simple tool", + parameters: undefined, + strict: true, + }, + }) + }) + + it("should convert a tool with required args", () => { + const tool = defineCustomTool({ + name: "greeter", + description: "Greets a person", + parameters: z.object({ + name: z.string().describe("Person's name"), + }), + async execute({ name }) { + return `Hello, ${name}!` + }, + }) + + const serialized = serializeCustomTool(tool) + const result = formatNative(serialized) + + expect(result.type).toBe("function") + expect(result.function.name).toBe("greeter") + expect(result.function.description).toBe("Greets a person") + expect(result.function.parameters?.properties).toEqual({ + name: { + type: "string", + description: "Person's name", + }, + }) + expect(result.function.parameters?.required).toEqual(["name"]) + expect(result.function.parameters?.additionalProperties).toBe(false) + }) + + it("should convert a tool with optional args", () => { + const tool = defineCustomTool({ + name: "optional_tool", + description: "Tool with optional args", + parameters: z.object({ + format: z.string().optional().describe("Output format"), + }), + async execute() { + return "done" + }, + }) + + const serialized = serializeCustomTool(tool) + const result = formatNative(serialized) + + expect(result.function.parameters?.required).toEqual([]) + expect(result.function.parameters?.properties).toEqual({ + format: { + type: "string", + description: "Output format", + }, + }) + }) + + it("should convert a tool with mixed required and optional args", () => { + const tool = defineCustomTool({ + name: "mixed_tool", + description: "Tool with mixed args", + parameters: z.object({ + input: z.string().describe("Required input"), + options: z.object({}).optional().describe("Optional config"), + count: z.number().describe("Also required"), + }), + async execute() { + return "done" + }, + }) + + const serialized = serializeCustomTool(tool) + const result = formatNative(serialized) + + expect(result.function.parameters?.required).toEqual(["input", "count"]) + expect(result.function.parameters?.properties).toEqual({ + input: { + type: "string", + description: "Required input", + }, + options: { + additionalProperties: false, + properties: {}, + type: "object", + description: "Optional config", + }, + count: { + type: "number", + description: "Also required", + }, + }) + }) + + it("should map type strings to JSON Schema types", () => { + const tool = defineCustomTool({ + name: "typed_tool", + description: "Tool with various types", + parameters: z.object({ + str: z.string().describe("A string"), + num: z.number().describe("A number"), + bool: z.boolean().describe("A boolean"), + obj: z.object({}).describe("An object"), + arr: z.array(z.string()).describe("An array"), + }), + async execute() { + return "done" + }, + }) + + const serialized = serializeCustomTool(tool) + const result = formatNative(serialized) + const props = result.function.parameters?.properties as + | Record + | undefined + + expect(props?.str?.type).toBe("string") + expect(props?.num?.type).toBe("number") + expect(props?.bool?.type).toBe("boolean") + expect(props?.obj?.type).toBe("object") + expect(props?.arr?.type).toBe("array") + }) + + it("should pass through raw parameters as-is", () => { + // formatNative is a simple wrapper that passes through parameters unchanged + const serialized = { + name: "test_tool", + description: "Tool with specific type", + parameters: { + type: "object", + properties: { + data: { type: "integer", description: "Integer type" }, + }, + }, + } as SerializedCustomToolDefinition + + const result = formatNative(serialized) + + expect(result.type).toBe("function") + expect(result.function.name).toBe("test_tool") + const props = result.function.parameters?.properties as Record | undefined + expect(props?.data?.type).toBe("integer") + }) + + it("should convert multiple tools", () => { + const tools = [ + defineCustomTool({ + name: "tool_a", + description: "First tool", + async execute() { + return "a" + }, + }), + defineCustomTool({ + name: "tool_b", + description: "Second tool", + async execute() { + return "b" + }, + }), + ] + + const serialized = serializeCustomTools(tools) + const result = serialized.map(formatNative) + + expect(result).toHaveLength(2) + expect(result[0]?.function.name).toBe("tool_a") + expect(result[1]?.function.name).toBe("tool_b") + expect(result.every((t) => t.type === "function")).toBe(true) + }) +}) + +describe("Native Protocol snapshots", () => { + it("should generate correct native definition for simple tool", () => { + const serialized = serializeCustomTool(fixtureTools.simple) + const result = formatNative(serialized) + expect(result).toMatchSnapshot() + }) + + it("should generate correct native definition for cached tool", () => { + const serialized = serializeCustomTool(fixtureTools.cached) + const result = formatNative(serialized) + expect(result).toMatchSnapshot() + }) + + it("should generate correct native definition for legacy tool (using args)", () => { + const serialized = serializeCustomTool(fixtureTools.legacy) + const result = formatNative(serialized) + expect(result).toMatchSnapshot() + }) + + it("should generate correct native definitions for multi export tools", () => { + const serializedA = serializeCustomTool(fixtureTools.multi_toolA) + const serializedB = serializeCustomTool(fixtureTools.multi_toolB) + const result = [serializedA, serializedB].map(formatNative) + expect(result).toMatchSnapshot() + }) + + it("should generate correct native definition for mixed export tool", () => { + const serialized = serializeCustomTool(fixtureTools.mixed_validTool) + const result = formatNative(serialized) + expect(result).toMatchSnapshot() + }) + + it("should generate correct native definitions for all fixtures combined", () => { + const allSerialized = Object.values(fixtureTools).map(serializeCustomTool) + const result = allSerialized.map(formatNative) + expect(result).toMatchSnapshot() + }) +}) diff --git a/packages/core/src/custom-tools/__tests__/format-xml.spec.ts b/packages/core/src/custom-tools/__tests__/format-xml.spec.ts new file mode 100644 index 0000000000..0be0772347 --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/format-xml.spec.ts @@ -0,0 +1,192 @@ +// pnpm --filter @roo-code/core test src/custom-tools/__tests__/format-xml.spec.ts + +import { type SerializedCustomToolDefinition, parametersSchema as z, defineCustomTool } from "@roo-code/types" + +import { serializeCustomTool, serializeCustomTools } from "../serialize.js" +import { formatXml } from "../format-xml.js" + +import simpleTool from "./fixtures/simple.js" +import cachedTool from "./fixtures/cached.js" +import legacyTool from "./fixtures/legacy.js" +import { toolA, toolB } from "./fixtures/multi.js" +import { validTool as mixedValidTool } from "./fixtures/mixed.js" + +const fixtureTools = { + simple: simpleTool, + cached: cachedTool, + legacy: legacyTool, + multi_toolA: toolA, + multi_toolB: toolB, + mixed_validTool: mixedValidTool, +} + +describe("formatXml", () => { + it("should return empty string for empty tools array", () => { + expect(formatXml([])).toBe("") + }) + + it("should throw for undefined tools", () => { + expect(() => formatXml(undefined as unknown as SerializedCustomToolDefinition[])).toThrow() + }) + + it("should generate description for a single tool without args", () => { + const tool = defineCustomTool({ + name: "my_tool", + description: "A simple tool that does something", + async execute() { + return "done" + }, + }) + + const serialized = serializeCustomTool(tool) + const result = formatXml([serialized]) + + expect(result).toContain("# Custom Tools") + expect(result).toContain("## my_tool") + expect(result).toContain("Description: A simple tool that does something") + expect(result).toContain("Parameters: None") + expect(result).toContain("") + expect(result).toContain("") + }) + + it("should generate description for a tool with required args", () => { + const tool = defineCustomTool({ + name: "greeter", + description: "Greets a person by name", + parameters: z.object({ + name: z.string().describe("The name of the person to greet"), + }), + async execute({ name }) { + return `Hello, ${name}!` + }, + }) + + const serialized = serializeCustomTool(tool) + const result = formatXml([serialized]) + + expect(result).toContain("## greeter") + expect(result).toContain("Description: Greets a person by name") + expect(result).toContain("Parameters:") + expect(result).toContain("- name: (required) The name of the person to greet (type: string)") + expect(result).toContain("") + expect(result).toContain("name value here") + expect(result).toContain("") + }) + + it("should generate description for a tool with optional args", () => { + const tool = defineCustomTool({ + name: "configurable_tool", + description: "A tool with optional configuration", + parameters: z.object({ + input: z.string().describe("The input to process"), + format: z.string().optional().describe("Output format"), + }), + async execute({ input, format }) { + return format ? `${input} (${format})` : input + }, + }) + + const serialized = serializeCustomTool(tool) + const result = formatXml([serialized]) + + expect(result).toContain("- input: (required) The input to process (type: string)") + expect(result).toContain("- format: (optional) Output format (type: string)") + expect(result).toContain("input value here") + expect(result).toContain("optional format value") + }) + + it("should generate descriptions for multiple tools", () => { + const tools = [ + defineCustomTool({ + name: "tool_a", + description: "First tool", + async execute() { + return "a" + }, + }), + defineCustomTool({ + name: "tool_b", + description: "Second tool", + parameters: z.object({ + value: z.number().describe("A numeric value"), + }), + async execute() { + return "b" + }, + }), + ] + + const serialized = serializeCustomTools(tools) + const result = formatXml(serialized) + + expect(result).toContain("## tool_a") + expect(result).toContain("Description: First tool") + expect(result).toContain("## tool_b") + expect(result).toContain("Description: Second tool") + expect(result).toContain("- value: (required) A numeric value (type: number)") + }) + + it("should treat args in required array as required", () => { + // Using a raw SerializedToolDefinition to test the required behavior. + const tools: SerializedCustomToolDefinition[] = [ + { + name: "test_tool", + description: "Test tool", + parameters: { + type: "object", + properties: { + data: { + type: "object", + description: "Some data", + }, + }, + required: ["data"], + }, + }, + ] + + const result = formatXml(tools) + + expect(result).toContain("- data: (required) Some data (type: object)") + expect(result).toContain("data value here") + }) +}) + +describe("XML Protocol snapshots", () => { + it("should generate correct XML description for simple tool", () => { + const serialized = serializeCustomTool(fixtureTools.simple) + const result = formatXml([serialized]) + expect(result).toMatchSnapshot() + }) + + it("should generate correct XML description for cached tool", () => { + const serialized = serializeCustomTool(fixtureTools.cached) + const result = formatXml([serialized]) + expect(result).toMatchSnapshot() + }) + + it("should generate correct XML description for legacy tool (using args)", () => { + const serialized = serializeCustomTool(fixtureTools.legacy) + const result = formatXml([serialized]) + expect(result).toMatchSnapshot() + }) + + it("should generate correct XML description for multi export tools", () => { + const serializedA = serializeCustomTool(fixtureTools.multi_toolA) + const serializedB = serializeCustomTool(fixtureTools.multi_toolB) + const result = formatXml([serializedA, serializedB]) + expect(result).toMatchSnapshot() + }) + + it("should generate correct XML description for mixed export tool", () => { + const serialized = serializeCustomTool(fixtureTools.mixed_validTool) + const result = formatXml([serialized]) + expect(result).toMatchSnapshot() + }) + + it("should generate correct XML description for all fixtures combined", () => { + const allSerialized = Object.values(fixtureTools).map(serializeCustomTool) + const result = formatXml(allSerialized) + expect(result).toMatchSnapshot() + }) +}) diff --git a/packages/core/src/custom-tools/__tests__/serialize.spec.ts b/packages/core/src/custom-tools/__tests__/serialize.spec.ts new file mode 100644 index 0000000000..05c125f49e --- /dev/null +++ b/packages/core/src/custom-tools/__tests__/serialize.spec.ts @@ -0,0 +1,225 @@ +// pnpm --filter @roo-code/core test src/custom-tools/__tests__/serialize.spec.ts + +import { parametersSchema as z, defineCustomTool } from "@roo-code/types" + +import { serializeCustomTool, serializeCustomTools } from "../serialize.js" + +import simpleTool from "./fixtures/simple.js" +import cachedTool from "./fixtures/cached.js" +import legacyTool from "./fixtures/legacy.js" +import { toolA, toolB } from "./fixtures/multi.js" +import { validTool as mixedValidTool } from "./fixtures/mixed.js" + +const fixtureTools = { + simple: simpleTool, + cached: cachedTool, + legacy: legacyTool, + multi_toolA: toolA, + multi_toolB: toolB, + mixed_validTool: mixedValidTool, +} + +describe("serializeCustomTool", () => { + it("should serialize a tool without parameters", () => { + const tool = defineCustomTool({ + name: "simple_tool", + description: "A simple tool that does something", + async execute() { + return "done" + }, + }) + + const result = serializeCustomTool(tool) + + expect(result).toEqual({ + name: "simple_tool", + description: "A simple tool that does something", + }) + }) + + it("should serialize a tool with required string parameter", () => { + const tool = defineCustomTool({ + name: "greeter", + description: "Greets a person by name", + parameters: z.object({ + name: z.string().describe("The name of the person to greet"), + }), + async execute({ name }) { + return `Hello, ${name}!` + }, + }) + + const result = serializeCustomTool(tool) + + expect(result.name).toBe("greeter") + expect(result.description).toBe("Greets a person by name") + expect(result.parameters?.properties?.name).toEqual({ + type: "string", + description: "The name of the person to greet", + }) + expect(result.parameters?.required).toEqual(["name"]) + }) + + it("should serialize a tool with optional parameter", () => { + const tool = defineCustomTool({ + name: "configurable_tool", + description: "A tool with optional configuration", + parameters: z.object({ + input: z.string().describe("The input to process"), + format: z.string().optional().describe("Output format"), + }), + async execute({ input, format }) { + return format ? `${input} (${format})` : input + }, + }) + + const result = serializeCustomTool(tool) + + expect(result.parameters?.properties?.input).toEqual({ + type: "string", + description: "The input to process", + }) + + expect(result.parameters?.properties?.format).toEqual({ + type: "string", + description: "Output format", + }) + + // Only required params should be in the required array + expect(result.parameters?.required).toEqual(["input"]) + }) + + it("should serialize a tool with various types", () => { + const tool = defineCustomTool({ + name: "typed_tool", + description: "Tool with various types", + parameters: z.object({ + str: z.string().describe("A string"), + num: z.number().describe("A number"), + bool: z.boolean().describe("A boolean"), + obj: z.object({}).describe("An object"), + arr: z.array(z.string()).describe("An array"), + }), + async execute() { + return "done" + }, + }) + + const result = serializeCustomTool(tool) + + expect(result.parameters?.properties?.str).toEqual({ + description: "A string", + type: "string", + }) + expect(result.parameters?.properties?.num).toEqual({ + description: "A number", + type: "number", + }) + expect(result.parameters?.properties?.bool).toEqual({ + description: "A boolean", + type: "boolean", + }) + expect(result.parameters?.properties?.obj).toEqual({ + additionalProperties: false, + description: "An object", + properties: {}, + type: "object", + }) + expect(result.parameters?.properties?.arr).toEqual({ + description: "An array", + items: { type: "string" }, + type: "array", + }) + }) + + it("should handle nullable parameters as optional", () => { + const tool = defineCustomTool({ + name: "nullable_tool", + description: "Tool with nullable param", + parameters: z.object({ + value: z.string().nullable().describe("A nullable value"), + }), + async execute() { + return "done" + }, + }) + + const result = serializeCustomTool(tool) + + expect(result.parameters?.required).toEqual(["value"]) + }) + + it("should handle default values as optional", () => { + const tool = defineCustomTool({ + name: "default_tool", + description: "Tool with default param", + parameters: z.object({ + count: z.number().default(10).describe("A count with default"), + }), + async execute() { + return "done" + }, + }) + + const result = serializeCustomTool(tool) + + expect(result.parameters?.required).toEqual(["count"]) + }) +}) + +describe("serializeCustomTools", () => { + it("should return empty array for empty tools array", () => { + expect(serializeCustomTools([])).toEqual([]) + }) + + it("should serialize multiple tools", () => { + const tools = [ + defineCustomTool({ + name: "tool_a", + description: "First tool", + async execute() { + return "a" + }, + }), + defineCustomTool({ + name: "tool_b", + description: "Second tool", + parameters: z.object({ + value: z.number().describe("A numeric value"), + }), + async execute() { + return "b" + }, + }), + ] + + const result = serializeCustomTools(tools) + + expect(result).toHaveLength(2) + expect(result[0]?.name).toBe("tool_a") + expect(result[1]?.name).toBe("tool_b") + expect(result[1]?.parameters?.properties?.value).toBeDefined() + }) +}) + +describe("Serialization snapshots", () => { + it("should correctly serialize simple tool", () => { + const result = serializeCustomTool(fixtureTools.simple) + expect(result).toMatchSnapshot() + }) + + it("should correctly serialize cached tool", () => { + const result = serializeCustomTool(fixtureTools.cached) + expect(result).toMatchSnapshot() + }) + + it("should correctly serialize legacy tool (using args)", () => { + const result = serializeCustomTool(fixtureTools.legacy) + expect(result).toMatchSnapshot() + }) + + it("should correctly serialize all fixtures", () => { + const result = Object.values(fixtureTools).map(serializeCustomTool) + expect(result).toMatchSnapshot() + }) +}) diff --git a/packages/core/src/custom-tools/custom-tool-registry.ts b/packages/core/src/custom-tools/custom-tool-registry.ts new file mode 100644 index 0000000000..1725f4aba3 --- /dev/null +++ b/packages/core/src/custom-tools/custom-tool-registry.ts @@ -0,0 +1,432 @@ +/** + * CustomToolRegistry - A reusable class for dynamically loading and managing TypeScript tools. + * + * Features: + * - Dynamic TypeScript/JavaScript tool loading with esbuild transpilation. + * - Runtime validation of tool definitions. + * - Tool execution with context. + * - JSON Schema generation for LLM integration. + */ + +import fs from "fs" +import path from "path" +import { createHash } from "crypto" +import os from "os" + +import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types" + +import type { StoredCustomTool, LoadResult } from "./types.js" +import { serializeCustomTool } from "./serialize.js" +import { runEsbuild, NODE_BUILTIN_MODULES, COMMONJS_REQUIRE_BANNER } from "./esbuild-runner.js" + +export interface RegistryOptions { + /** Directory for caching compiled TypeScript files. */ + cacheDir?: string + /** Additional paths for resolving node modules (useful for tools outside node_modules). */ + nodePaths?: string[] + /** Path to the extension root directory (for finding bundled esbuild binary in production). */ + extensionPath?: string +} + +export class CustomToolRegistry { + private tools = new Map() + private tsCache = new Map() + private cacheDir: string + private nodePaths: string[] + private extensionPath?: string + private lastLoaded: Map = new Map() + + constructor(options?: RegistryOptions) { + this.cacheDir = options?.cacheDir ?? path.join(os.tmpdir(), "dynamic-tools-cache") + // Default to current working directory's node_modules. + this.nodePaths = options?.nodePaths ?? [path.join(process.cwd(), "node_modules")] + this.extensionPath = options?.extensionPath + } + + /** + * Load all tools from a directory. + * Supports both .ts and .js files. + * + * @param toolDir - Absolute path to the tools directory + * @returns LoadResult with lists of loaded and failed tools + */ + async loadFromDirectory(toolDir: string): Promise { + const result: LoadResult = { loaded: [], failed: [] } + + try { + if (!fs.existsSync(toolDir)) { + return result + } + + const files = fs.readdirSync(toolDir).filter((f) => f.endsWith(".ts") || f.endsWith(".js")) + + for (const file of files) { + const filePath = path.join(toolDir, file) + + try { + console.log(`[CustomToolRegistry] importing tool from ${filePath}`) + const mod = await this.import(filePath) + + for (const [exportName, value] of Object.entries(mod)) { + const def = this.validate(exportName, value) + + if (!def) { + continue + } + + this.tools.set(def.name, { ...def, source: filePath }) + console.log(`[CustomToolRegistry] loaded tool ${def.name} from ${filePath}`) + result.loaded.push(def.name) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[CustomToolRegistry] import(${filePath}) failed: ${message}`) + result.failed.push({ file, error: message }) + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[CustomToolRegistry] loadFromDirectory(${toolDir}) failed: ${message}`) + } + + return result + } + + async loadFromDirectoryIfStale(toolDir: string): Promise { + if (!fs.existsSync(toolDir)) { + return { loaded: [], failed: [] } + } + + const lastLoaded = this.lastLoaded.get(toolDir) + const stat = fs.statSync(toolDir) + const isStale = lastLoaded ? stat.mtimeMs > lastLoaded : true + + if (isStale) { + this.lastLoaded.set(toolDir, stat.mtimeMs) + return this.loadFromDirectory(toolDir) + } + + return { loaded: this.list(), failed: [] } + } + + /** + * Load all tools from multiple directories. + * Directories are processed in order, so later directories can override tools from earlier ones. + * Supports both .ts and .js files. + * + * @param toolDirs - Array of absolute paths to tools directories + * @returns LoadResult with lists of loaded and failed tools from all directories + */ + async loadFromDirectories(toolDirs: string[]): Promise { + const result: LoadResult = { loaded: [], failed: [] } + + for (const toolDir of toolDirs) { + const dirResult = await this.loadFromDirectory(toolDir) + result.loaded.push(...dirResult.loaded) + result.failed.push(...dirResult.failed) + } + + return result + } + + /** + * Load all tools from multiple directories if any has become stale. + * Directories are processed in order, so later directories can override tools from earlier ones. + * + * @param toolDirs - Array of absolute paths to tools directories + * @returns LoadResult with lists of loaded and failed tools + */ + async loadFromDirectoriesIfStale(toolDirs: string[]): Promise { + const result: LoadResult = { loaded: [], failed: [] } + + for (const toolDir of toolDirs) { + const dirResult = await this.loadFromDirectoryIfStale(toolDir) + result.loaded.push(...dirResult.loaded) + result.failed.push(...dirResult.failed) + } + + return result + } + + /** + * Register a tool directly (without loading from file). + */ + register(definition: CustomToolDefinition, source?: string): void { + const { name: id } = definition + const validated = this.validate(id, definition) + + if (!validated) { + throw new Error(`Invalid tool definition for '${id}'`) + } + + const storedTool: StoredCustomTool = source ? { ...validated, source } : validated + this.tools.set(id, storedTool) + } + + /** + * Unregister a tool by ID. + */ + unregister(id: string): boolean { + return this.tools.delete(id) + } + + /** + * Get a tool by ID. + */ + get(id: string): CustomToolDefinition | undefined { + return this.tools.get(id) + } + + /** + * Check if a tool exists. + */ + has(id: string): boolean { + return this.tools.has(id) + } + + /** + * Get all registered tool IDs. + */ + list(): string[] { + return Array.from(this.tools.keys()) + } + + /** + * Get all registered tools. + */ + getAll(): CustomToolDefinition[] { + return Array.from(this.tools.values()) + } + + /** + * Get all registered tools in the serialized format. + */ + getAllSerialized(): SerializedCustomToolDefinition[] { + return this.getAll().map(serializeCustomTool) + } + + /** + * Get the number of registered tools. + */ + get size(): number { + return this.tools.size + } + + /** + * Clear all registered tools. + */ + clear(): void { + this.tools.clear() + } + + /** + * Set the extension path for finding bundled esbuild binary. + * This should be called with context.extensionPath when the extension activates. + */ + setExtensionPath(extensionPath: string): void { + this.extensionPath = extensionPath + } + + /** + * Get the current extension path. + */ + getExtensionPath(): string | undefined { + return this.extensionPath + } + + /** + * 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 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) { + console.error( + `[CustomToolRegistry] clearCache failed to clean disk cache: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + + /** + * 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) + const ext = path.extname(absolutePath) + + if (ext === ".js" || ext === ".mjs") { + return import(`file://${absolutePath}`) + } + + const stat = fs.statSync(absolutePath) + const cacheKey = `${absolutePath}:${stat.mtimeMs}` + + // Check if we have a cached version in memory. + if (this.tsCache.has(cacheKey)) { + const cachedPath = this.tsCache.get(cacheKey)! + return import(`file://${cachedPath}`) + } + + const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16) + + // 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)) { + this.tsCache.set(cacheKey, tempFile) + 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, + outfile: tempFile, + format: "esm", + platform: "node", + target: "node18", + bundle: true, + sourcemap: "inline", + packages: "bundle", + 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. + */ + private isParametersSchema(value: unknown): value is CustomToolParametersSchema { + return ( + value !== null && + typeof value === "object" && + "_def" in value && + typeof (value as Record)._def === "object" + ) + } + + /** + * Validate a tool definition and return a typed result. + * Returns null for non-tool exports, throws for invalid tools. + */ + private validate(exportName: string, value: unknown): CustomToolDefinition | null { + // Quick pre-check to filter out non-objects. + if (!value || typeof value !== "object") { + return null + } + + // Check if it looks like a tool (has execute function). + if (!("execute" in value) || typeof (value as Record).execute !== "function") { + return null + } + + const obj = value as Record + const errors: string[] = [] + + // Validate name. + if (typeof obj.name !== "string") { + errors.push("name: Expected string") + } else if (obj.name.length === 0) { + errors.push("name: Tool must have a non-empty name") + } + + // Validate description. + if (typeof obj.description !== "string") { + errors.push("description: Expected string") + } else if (obj.description.length === 0) { + errors.push("description: Tool must have a non-empty description") + } + + // Validate parameters (optional). + if (obj.parameters !== undefined && !this.isParametersSchema(obj.parameters)) { + errors.push("parameters: parameters must be a Zod schema") + } + + if (errors.length > 0) { + throw new Error(`Invalid tool definition for '${exportName}': ${errors.join(", ")}`) + } + + return value as CustomToolDefinition + } +} + +export const customToolRegistry = new CustomToolRegistry() diff --git a/packages/core/src/custom-tools/esbuild-runner.ts b/packages/core/src/custom-tools/esbuild-runner.ts new file mode 100644 index 0000000000..6f62143551 --- /dev/null +++ b/packages/core/src/custom-tools/esbuild-runner.ts @@ -0,0 +1,209 @@ +/** + * esbuild-runner - Runs esbuild-wasm CLI to transpile TypeScript files. + * + * This module provides a way to run esbuild as a CLI process instead of using + * the JavaScript API. This uses esbuild-wasm which is cross-platform and works + * on all operating systems without needing native binaries. + * + * In production, the esbuild-wasm CLI script is bundled in dist/bin/. + * In development, it falls back to using esbuild-wasm from node_modules. + */ + +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 { + // In ESM context, import.meta.url is available. + // In bundled CJS, this will throw or be undefined. + if (typeof import.meta !== "undefined" && import.meta.url) { + return path.dirname(fileURLToPath(import.meta.url)) + } + } catch { + // Ignore errors, fall through to undefined. + } + + return undefined +} + +const moduleDir = getModuleDir() + +export interface EsbuildOptions { + /** Entry point file path (absolute) */ + entryPoint: string + /** Output file path (absolute) */ + outfile: string + /** Output format */ + format?: "esm" | "cjs" | "iife" + /** Target platform */ + platform?: "node" | "browser" | "neutral" + /** Target environment (e.g., "node18") */ + target?: string + /** Bundle dependencies */ + bundle?: boolean + /** Generate source maps */ + sourcemap?: boolean | "inline" | "external" + /** How to handle packages: "bundle" includes them, "external" leaves them */ + 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 +} + +/** + * Find the esbuild-wasm CLI script by walking up the directory tree. + * In pnpm monorepos, node_modules/esbuild-wasm is a symlink to the actual package, + * so we don't need special pnpm handling. + */ +function findEsbuildWasmScript(startDir: string): string | null { + const maxDepth = 10 + let currentDir = path.resolve(startDir) + const root = path.parse(currentDir).root + + for (let i = 0; i < maxDepth && currentDir !== root; i++) { + // Check node_modules/esbuild-wasm/bin/esbuild at this level. + const scriptPath = path.join(currentDir, "node_modules", "esbuild-wasm", "bin", "esbuild") + + if (fs.existsSync(scriptPath)) { + return scriptPath + } + + // Also check src/node_modules for monorepo where src is a workspace. + const srcScriptPath = path.join(currentDir, "src", "node_modules", "esbuild-wasm", "bin", "esbuild") + + if (fs.existsSync(srcScriptPath)) { + return srcScriptPath + } + + currentDir = path.dirname(currentDir) + } + + return null +} + +/** + * Get the path to the esbuild CLI script. + * + * Resolution order: + * 1. Production: Look in extension's dist/bin directory for bundled script. + * 2. Development: Use esbuild-wasm from node_modules (relative to this module). + * 3. Fallback: Try process.cwd() as last resort. + * + * @param extensionPath - Path to the extension's root directory (production) + * @returns Path to the esbuild CLI script + */ +export function getEsbuildScriptPath(extensionPath?: string): string { + // Production: look in extension's dist/bin directory. + if (extensionPath) { + const prodPath = path.join(extensionPath, "dist", "bin", "esbuild") + + if (fs.existsSync(prodPath)) { + return prodPath + } + } + + // Development: use esbuild-wasm from node_modules relative to this module. + // This works when running the extension in debug mode (if moduleDir is available). + if (moduleDir) { + const devPath = findEsbuildWasmScript(moduleDir) + + if (devPath) { + return devPath + } + } + + // Fallback: try from cwd (for tests and other contexts). + const cwdPath = findEsbuildWasmScript(process.cwd()) + + if (cwdPath) { + return cwdPath + } + + throw new Error("esbuild-wasm CLI not found. Ensure esbuild-wasm is installed.") +} + +/** + * Run esbuild CLI to bundle a TypeScript file. + * + * Uses esbuild-wasm which is cross-platform and runs via Node.js. + * + * @param options - Build options + * @param extensionPath - Path to extension root (for finding bundled script) + * @returns Promise that resolves when build completes + * @throws Error if the build fails + */ +export async function runEsbuild(options: EsbuildOptions, extensionPath?: string): Promise { + const scriptPath = getEsbuildScriptPath(extensionPath) + + const args: string[] = [ + scriptPath, + options.entryPoint, + `--outfile=${options.outfile}`, + `--format=${options.format ?? "esm"}`, + `--platform=${options.platform ?? "node"}`, + `--target=${options.target ?? "node18"}`, + ] + + if (options.bundle !== false) { + args.push("--bundle") + } + + if (options.sourcemap) { + args.push(options.sourcemap === true ? "--sourcemap" : `--sourcemap=${options.sourcemap}`) + } + + if (options.packages) { + 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 } + + if (options.nodePaths && options.nodePaths.length > 0) { + env.NODE_PATH = options.nodePaths.join(path.delimiter) + } + + try { + await execa(process.execPath, args, { env, stdin: "ignore" }) + } catch (error) { + const execaError = error as { stderr?: string; stdout?: string; exitCode?: number; message: string } + const errorMessage = execaError.stderr || execaError.stdout || `esbuild exited with code ${execaError.exitCode}` + throw new Error(`esbuild failed: ${errorMessage}`) + } +} diff --git a/packages/core/src/custom-tools/format-native.ts b/packages/core/src/custom-tools/format-native.ts new file mode 100644 index 0000000000..c1c0018d62 --- /dev/null +++ b/packages/core/src/custom-tools/format-native.ts @@ -0,0 +1,23 @@ +import type { OpenAI } from "openai" + +import type { SerializedCustomToolDefinition } from "@roo-code/types" + +export function formatNative(tool: SerializedCustomToolDefinition): OpenAI.Chat.ChatCompletionFunctionTool { + // Create a shallow copy to avoid mutating the input object + let parameters = tool.parameters + + if (parameters) { + // Create a new object with the modifications instead of mutating the original + parameters = { ...parameters } + + // We don't need the $schema property; none of the other tools specify it. + delete parameters["$schema"] + + // https://community.openai.com/t/on-the-function-calling-what-about-if-i-have-no-parameter-to-call/516876 + if (!parameters.required) { + parameters.required = [] + } + } + + return { type: "function", function: { ...tool, strict: true, parameters } } +} diff --git a/packages/core/src/custom-tools/format-xml.ts b/packages/core/src/custom-tools/format-xml.ts new file mode 100644 index 0000000000..01338f236c --- /dev/null +++ b/packages/core/src/custom-tools/format-xml.ts @@ -0,0 +1,89 @@ +import type { SerializedCustomToolDefinition, SerializedCustomToolParameters } from "@roo-code/types" + +/** + * Extract the type string from a parameter schema. + * Handles both direct `type` property and `anyOf` schemas (used for nullable types). + */ +function getParameterType(parameter: SerializedCustomToolParameters): string { + // Direct type property + if (parameter.type) { + return String(parameter.type) + } + + // Handle anyOf schema (used for nullable types like `string | null`) + if (parameter.anyOf && Array.isArray(parameter.anyOf)) { + const types = parameter.anyOf + .map((schema) => (typeof schema === "object" && schema.type ? String(schema.type) : null)) + .filter((t): t is string => t !== null && t !== "null") + + if (types.length > 0) { + return types.join(" | ") + } + } + + return "unknown" +} + +function getParameterDescription(name: string, parameter: SerializedCustomToolParameters, required: string[]): string { + const requiredText = required.includes(name) ? "(required)" : "(optional)" + const typeText = getParameterType(parameter) + return `- ${name}: ${requiredText} ${parameter.description ?? ""} (type: ${typeText})` +} + +function getUsage(tool: SerializedCustomToolDefinition): string { + const lines: string[] = [`<${tool.name}>`] + + if (tool.parameters) { + const required = tool.parameters.required ?? [] + + for (const [argName, _argType] of Object.entries(tool.parameters.properties ?? {})) { + const placeholder = required.includes(argName) ? `${argName} value here` : `optional ${argName} value` + lines.push(`<${argName}>${placeholder}`) + } + } + + lines.push(``) + return lines.join("\n") +} + +function getDescription(tool: SerializedCustomToolDefinition): string { + const parts: string[] = [] + + parts.push(`## ${tool.name}`) + parts.push(`Description: ${tool.description}`) + + if (tool.parameters?.properties) { + const required = tool.parameters?.required ?? [] + parts.push("Parameters:") + + for (const [name, parameter] of Object.entries(tool.parameters.properties)) { + // What should we do with `boolean` values for `parameter`? + if (typeof parameter !== "object") { + continue + } + + parts.push(getParameterDescription(name, parameter, required)) + } + } else { + parts.push("Parameters: None") + } + + parts.push("Usage:") + parts.push(getUsage(tool)) + + return parts.join("\n") +} + +export function formatXml(tools: SerializedCustomToolDefinition[]): string { + if (tools.length === 0) { + return "" + } + + const descriptions = tools.map((tool) => getDescription(tool)) + + return `# Custom Tools + +The following custom tools are available for this mode. Use them in the same way as built-in tools. + +${descriptions.join("\n\n")}` +} diff --git a/packages/core/src/custom-tools/index.ts b/packages/core/src/custom-tools/index.ts new file mode 100644 index 0000000000..c8b44ec117 --- /dev/null +++ b/packages/core/src/custom-tools/index.ts @@ -0,0 +1,4 @@ +export * from "./custom-tool-registry.js" +export * from "./serialize.js" +export * from "./format-xml.js" +export * from "./format-native.js" diff --git a/packages/core/src/custom-tools/serialize.ts b/packages/core/src/custom-tools/serialize.ts new file mode 100644 index 0000000000..c347bf0be1 --- /dev/null +++ b/packages/core/src/custom-tools/serialize.ts @@ -0,0 +1,21 @@ +import { type SerializedCustomToolDefinition, parametersSchema } from "@roo-code/types" + +import type { StoredCustomTool } from "./types.js" + +export function serializeCustomTool({ + name, + description, + parameters, + source, +}: StoredCustomTool): SerializedCustomToolDefinition { + return { + name, + description, + parameters: parameters ? parametersSchema.toJSONSchema(parameters) : undefined, + source, + } +} + +export function serializeCustomTools(tools: StoredCustomTool[]): SerializedCustomToolDefinition[] { + return tools.map(serializeCustomTool) +} diff --git a/packages/core/src/custom-tools/types.ts b/packages/core/src/custom-tools/types.ts new file mode 100644 index 0000000000..fbe871e789 --- /dev/null +++ b/packages/core/src/custom-tools/types.ts @@ -0,0 +1,8 @@ +import { type CustomToolDefinition } from "@roo-code/types" + +export type StoredCustomTool = CustomToolDefinition & { source?: string } + +export interface LoadResult { + loaded: string[] + failed: Array<{ file: string; error: string }> +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000000..fd7c93f68a --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1 @@ +export * from "./custom-tools/index.js" diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000000..2a73ee92bb --- /dev/null +++ b/packages/core/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/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000000..b6d6dbb880 --- /dev/null +++ b/packages/core/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/packages/evals/Dockerfile.runner b/packages/evals/Dockerfile.runner index 3d45d89810..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,25 +95,30 @@ 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 \ + packages/core \ packages/evals \ packages/ipc \ packages/telemetry \ packages/types \ packages/cloud \ + packages/vscode-shim \ src \ webview-ui @@ -98,14 +126,17 @@ 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/ +COPY ./packages/core/package.json ./packages/core/ COPY ./packages/evals/package.json ./packages/evals/ 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/ @@ -126,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/__tests__/messageLogDeduper.test.ts b/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts new file mode 100644 index 0000000000..3a7facb8c2 --- /dev/null +++ b/packages/evals/src/cli/__tests__/messageLogDeduper.test.ts @@ -0,0 +1,35 @@ +import { MessageLogDeduper } from "../messageLogDeduper.js" + +describe("MessageLogDeduper", () => { + it("dedupes identical messages for same action+ts", () => { + const d = new MessageLogDeduper() + const msg = { ts: 123, type: "say", say: "reasoning", text: "hello", partial: false } + + expect(d.shouldLog("updated", msg)).toBe(true) + expect(d.shouldLog("updated", msg)).toBe(false) + }) + + it("logs again if payload changes for same action+ts", () => { + const d = new MessageLogDeduper() + expect(d.shouldLog("updated", { ts: 123, text: "a" })).toBe(true) + expect(d.shouldLog("updated", { ts: 123, text: "b" })).toBe(true) + }) + + it("does not dedupe across different actions", () => { + const d = new MessageLogDeduper() + const msg = { ts: 123, text: "same" } + expect(d.shouldLog("created", msg)).toBe(true) + expect(d.shouldLog("updated", msg)).toBe(true) + }) + + it("evicts oldest entries", () => { + const d = new MessageLogDeduper(2) + + expect(d.shouldLog("updated", { ts: 1, text: "a" })).toBe(true) + expect(d.shouldLog("updated", { ts: 2, text: "b" })).toBe(true) + // causes eviction of ts:1 + expect(d.shouldLog("updated", { ts: 3, text: "c" })).toBe(true) + // ts:1 was evicted so it should log again + expect(d.shouldLog("updated", { ts: 1, text: "a" })).toBe(true) + }) +}) 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/messageLogDeduper.ts b/packages/evals/src/cli/messageLogDeduper.ts new file mode 100644 index 0000000000..ed0c7714b4 --- /dev/null +++ b/packages/evals/src/cli/messageLogDeduper.ts @@ -0,0 +1,50 @@ +export class MessageLogDeduper { + private readonly lastLoggedByKey = new Map() + + constructor(private readonly maxEntries = 10_000) {} + + /** + * Returns true if this message should be logged. + * Dedupe key: `${action}:${message.ts}`. + * Dedupe rule: skip if payload is identical to the last logged payload for that key. + */ + public shouldLog(action: string | undefined, message: unknown): boolean { + if (!action || !message || typeof message !== "object") { + return true + } + + const ts = (message as { ts?: unknown }).ts + if (typeof ts !== "number") { + return true + } + + let serialized: string + try { + serialized = JSON.stringify(message) + } catch { + // If serialization fails, prefer logging. + return true + } + + const key = `${action}:${ts}` + const prev = this.lastLoggedByKey.get(key) + if (prev === serialized) { + return false + } + + // Refresh insertion order so eviction removes true oldest. + if (this.lastLoggedByKey.has(key)) { + this.lastLoggedByKey.delete(key) + } + this.lastLoggedByKey.set(key, serialized) + + if (this.lastLoggedByKey.size > this.maxEntries) { + const oldestKey = this.lastLoggedByKey.keys().next().value as string | undefined + if (oldestKey) { + this.lastLoggedByKey.delete(oldestKey) + } + } + + return true + } +} 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 57% rename from packages/evals/src/cli/runTask.ts rename to packages/evals/src/cli/runTaskInVscode.ts index d7f37e72a1..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,217 +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) @@ -300,11 +105,13 @@ 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", ] let isApiUnstable = false + const messageLogDeduper = new MessageLogDeduper() client.on(IpcMessageType.TaskEvent, async (taskEvent) => { const { eventName, payload } = taskEvent @@ -330,6 +137,15 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO (payload[0].message.say && loggableSays.includes(payload[0].message.say)) || payload[0].message.partial !== true) ) { + // Dedupe identical repeated message events (same message.ts + same payload) + if (eventName === RooCodeEventName.Message) { + const action = payload[0]?.action as string | undefined + const message = payload[0]?.message + if (!messageLogDeduper.shouldLog(action, message)) { + return + } + } + // Extract tool name for tool-related messages for clearer logging let logEventName: string = eventName if (eventName === RooCodeEventName.Message && payload[0]?.message?.ask === "tool") { @@ -398,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, @@ -502,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 d329b66e04..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.93.0", + "version": "1.99.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/__tests__/custom-tool.spec.ts b/packages/types/src/__tests__/custom-tool.spec.ts new file mode 100644 index 0000000000..9514b9fcaa --- /dev/null +++ b/packages/types/src/__tests__/custom-tool.spec.ts @@ -0,0 +1,89 @@ +import { + type CustomToolDefinition, + type CustomToolContext, + defineCustomTool, + parametersSchema as z, +} from "../custom-tool.js" +import type { TaskLike } from "../task.js" + +describe("custom-tool utilities", () => { + describe("z (Zod re-export)", () => { + it("should export z from zod", () => { + expect(z).toBeDefined() + expect(z.string).toBeInstanceOf(Function) + expect(z.object).toBeInstanceOf(Function) + expect(z.number).toBeInstanceOf(Function) + }) + + it("should allow creating schemas", () => { + const schema = z.object({ + name: z.string(), + count: z.number().optional(), + }) + + const result = schema.parse({ name: "test" }) + expect(result).toEqual({ name: "test" }) + }) + }) + + describe("defineCustomTool", () => { + it("should return the same definition object", () => { + const definition = { + name: "test-tool", + description: "Test tool", + parameters: z.object({ input: z.string() }), + execute: async (args: { input: string }) => `Result: ${args.input}`, + } + + const result = defineCustomTool(definition) + expect(result).toBe(definition) + }) + + it("should work without parameters", () => { + const tool = defineCustomTool({ + name: "no-params-tool", + description: "No params tool", + execute: async () => "done", + }) + + expect(tool.description).toBe("No params tool") + expect(tool.parameters).toBeUndefined() + }) + + it("should preserve type inference for execute args", async () => { + const tool = defineCustomTool({ + name: "typed-tool", + description: "Typed tool", + parameters: z.object({ + name: z.string(), + count: z.number(), + }), + execute: async (args) => { + // TypeScript should infer args as { name: string, count: number }. + return `Hello ${args.name}, count is ${args.count}` + }, + }) + + const context: CustomToolContext = { + mode: "code", + task: { taskId: "test-task-id" } as unknown as TaskLike, + } + + const result = await tool.execute({ name: "World", count: 42 }, context) + expect(result).toBe("Hello World, count is 42") + }) + }) + + describe("CustomToolDefinition type", () => { + it("should accept valid definitions", () => { + const def: CustomToolDefinition = { + name: "valid-tool", + description: "A valid tool", + parameters: z.object({}), + execute: async () => "result", + } + + expect(def.description).toBe("A valid tool") + }) + }) +}) diff --git a/packages/types/src/custom-tool.ts b/packages/types/src/custom-tool.ts new file mode 100644 index 0000000000..b59b51a175 --- /dev/null +++ b/packages/types/src/custom-tool.ts @@ -0,0 +1,104 @@ +import type { ZodType, z } from "zod/v4" + +import { TaskLike } from "./task.js" + +// Re-export from Zod for convenience. + +export { z as parametersSchema } from "zod/v4" + +export type CustomToolParametersSchema = ZodType + +export type SerializedCustomToolParameters = z.core.JSONSchema.JSONSchema + +/** + * Context provided to tool execute functions. + */ +export interface CustomToolContext { + mode: string + task: TaskLike +} + +/** + * Definition structure for a custom tool. + * + * Note: This interface uses simple types to avoid TypeScript performance issues + * with Zod's complex type inference. For type-safe parameter inference, use + * the `defineCustomTool` helper function instead of annotating with this interface. + */ +export interface CustomToolDefinition { + /** + * The name of the tool. + * This is used to identify the tool in the prompt and in the tool registry. + */ + name: string + + /** + * A description of what the tool does. + * This is shown to the AI model to help it decide when to use the tool. + */ + description: string + + /** + * Optional Zod schema defining the tool's parameters. + * Use `z.object({})` to define the shape of arguments. + */ + parameters?: CustomToolParametersSchema + + /** + * The function that executes the tool. + * + * @param args - The validated arguments + * @param context - Execution context with session and message info + * @returns A string result to return to the AI + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + execute: (args: any, context: CustomToolContext) => Promise +} + +export interface SerializedCustomToolDefinition { + name: string + description: string + parameters?: SerializedCustomToolParameters + source?: string +} + +/** + * Type-safe definition structure for a custom tool with inferred parameter types. + * Use this with `defineCustomTool` for full type inference. + * + * @template T - The Zod schema type for parameters + */ +export interface TypedCustomToolDefinition + extends Omit { + parameters?: T + execute: (args: z.infer, context: CustomToolContext) => Promise +} + +/** + * Helper function to define a custom tool with proper type inference. + * + * This is optional - you can also just export a plain object that matches + * the CustomToolDefinition interface. + * + * @example + * ```ts + * import { parametersSchema as z, defineCustomTool } from "@roo-code/types" + * + * export default defineCustomTool({ + * name: "add_numbers", + * description: "Add two numbers", + * parameters: z.object({ + * a: z.number().describe("First number"), + * b: z.number().describe("Second number"), + * }), + * async execute({ a, b }) { + * return `The sum is ${a + b}` + * } + * }) + * ``` + */ +export function defineCustomTool( + definition: TypedCustomToolDefinition, +): TypedCustomToolDefinition { + return definition +} diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index cc0aabd6f6..f6f701a25d 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -13,6 +13,7 @@ export const experimentIds = [ "imageGeneration", "runSlashCommand", "multipleNativeToolCalls", + "customTools", ] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -30,6 +31,7 @@ export const experimentsSchema = z.object({ imageGeneration: z.boolean().optional(), runSlashCommand: z.boolean().optional(), multipleNativeToolCalls: z.boolean().optional(), + customTools: z.boolean().optional(), }) export type Experiments = z.infer 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/index.ts b/packages/types/src/index.ts index 4ab60899df..c4e5088d63 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -3,6 +3,7 @@ export * from "./cloud.js" export * from "./codebase-index.js" export * from "./context-management.js" export * from "./cookie-consent.js" +export * from "./custom-tool.js" export * from "./events.js" export * from "./experiment.js" export * from "./followup.js" @@ -16,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/baseten.ts b/packages/types/src/providers/baseten.ts index 0eff95b516..eeb6b0d2d1 100644 --- a/packages/types/src/providers/baseten.ts +++ b/packages/types/src/providers/baseten.ts @@ -33,6 +33,7 @@ export const basetenModels = { contextWindow: 163_840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 2.55, outputPrice: 5.95, cacheWritesPrice: 0, @@ -44,6 +45,7 @@ export const basetenModels = { contextWindow: 163_840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 2.55, outputPrice: 5.95, cacheWritesPrice: 0, @@ -55,6 +57,7 @@ export const basetenModels = { contextWindow: 163_840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.77, outputPrice: 0.77, cacheWritesPrice: 0, @@ -66,6 +69,7 @@ export const basetenModels = { contextWindow: 163_840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.5, outputPrice: 1.5, cacheWritesPrice: 0, @@ -86,28 +90,6 @@ export const basetenModels = { description: "DeepSeek's hybrid reasoning model with efficient long context scaling with GPT-5 level performance", }, - "Qwen/Qwen3-235B-A22B-Instruct-2507": { - maxTokens: 16_384, - contextWindow: 262_144, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.22, - outputPrice: 0.8, - cacheWritesPrice: 0, - cacheReadsPrice: 0, - description: "Mixture-of-experts LLM with math and reasoning capabilities", - }, - "Qwen/Qwen3-Coder-480B-A35B-Instruct": { - maxTokens: 16_384, - contextWindow: 262_144, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.38, - outputPrice: 1.53, - cacheWritesPrice: 0, - cacheReadsPrice: 0, - description: "Mixture-of-experts LLM with advanced coding and reasoning capabilities", - }, "openai/gpt-oss-120b": { maxTokens: 16_384, contextWindow: 128_072, @@ -120,6 +102,30 @@ export const basetenModels = { cacheReadsPrice: 0, description: "Extremely capable general-purpose LLM with strong, controllable reasoning capabilities", }, + "Qwen/Qwen3-235B-A22B-Instruct-2507": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + supportsNativeTools: true, + inputPrice: 0.22, + outputPrice: 0.8, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + description: "Mixture-of-experts LLM with math and reasoning capabilities", + }, + "Qwen/Qwen3-Coder-480B-A35B-Instruct": { + maxTokens: 16_384, + contextWindow: 262_144, + supportsImages: false, + supportsPromptCache: false, + supportsNativeTools: true, + inputPrice: 0.38, + outputPrice: 1.53, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + description: "Mixture-of-experts LLM with advanced coding and reasoning capabilities", + }, "moonshotai/Kimi-K2-Instruct-0905": { maxTokens: 16_384, contextWindow: 262_000, diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts index 8dec37108a..da40e98f43 100644 --- a/packages/types/src/providers/bedrock.ts +++ b/packages/types/src/providers/bedrock.ts @@ -454,22 +454,6 @@ export const bedrockModels = { outputPrice: 0.6, description: "Amazon Titan Text Express", }, - "amazon.titan-text-embeddings-v1:0": { - maxTokens: 8192, - contextWindow: 8_000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.1, - description: "Amazon Titan Text Embeddings", - }, - "amazon.titan-text-embeddings-v2:0": { - maxTokens: 8192, - contextWindow: 8_000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.02, - description: "Amazon Titan Text Embeddings V2", - }, "moonshot.kimi-k2-thinking": { maxTokens: 32_000, contextWindow: 262_144, diff --git a/packages/types/src/providers/cerebras.ts b/packages/types/src/providers/cerebras.ts index 54b314b6db..8e0c2f9413 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, @@ -18,7 +18,7 @@ export const cerebrasModels = { description: "Highly intelligent general purpose model with up to 1,000 tokens/s", }, "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 +29,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 +40,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 +51,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/featherless.ts b/packages/types/src/providers/featherless.ts index 8d9f7ae4e6..63bcb98968 100644 --- a/packages/types/src/providers/featherless.ts +++ b/packages/types/src/providers/featherless.ts @@ -13,6 +13,7 @@ export const featherlessModels = { contextWindow: 32678, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0, outputPrice: 0, description: "DeepSeek V3 0324 model.", @@ -22,6 +23,7 @@ export const featherlessModels = { contextWindow: 32678, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0, outputPrice: 0, description: "DeepSeek R1 0528 model.", @@ -41,6 +43,7 @@ export const featherlessModels = { contextWindow: 32678, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0, outputPrice: 0, description: "GPT-OSS 120B model.", @@ -57,4 +60,4 @@ export const featherlessModels = { }, } as const satisfies Record -export const featherlessDefaultModelId: FeatherlessModelId = "deepseek-ai/DeepSeek-R1-0528" +export const featherlessDefaultModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" 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/groq.ts b/packages/types/src/providers/groq.ts index a8d8ca7c98..a22ad764ee 100644 --- a/packages/types/src/providers/groq.ts +++ b/packages/types/src/providers/groq.ts @@ -5,12 +5,7 @@ export type GroqModelId = | "llama-3.1-8b-instant" | "llama-3.3-70b-versatile" | "meta-llama/llama-4-scout-17b-16e-instruct" - | "meta-llama/llama-4-maverick-17b-128e-instruct" - | "mistral-saba-24b" - | "qwen-qwq-32b" | "qwen/qwen3-32b" - | "deepseek-r1-distill-llama-70b" - | "moonshotai/kimi-k2-instruct" | "moonshotai/kimi-k2-instruct-0905" | "openai/gpt-oss-120b" | "openai/gpt-oss-20b" @@ -52,33 +47,6 @@ export const groqModels = { outputPrice: 0.34, description: "Meta Llama 4 Scout 17B Instruct model, 128K context.", }, - "meta-llama/llama-4-maverick-17b-128e-instruct": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.2, - outputPrice: 0.6, - description: "Meta Llama 4 Maverick 17B Instruct model, 128K context.", - }, - "mistral-saba-24b": { - maxTokens: 8192, - contextWindow: 32768, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.79, - outputPrice: 0.79, - description: "Mistral Saba 24B model, 32K context.", - }, - "qwen-qwq-32b": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.29, - outputPrice: 0.39, - description: "Alibaba Qwen QwQ 32B model, 128K context.", - }, "qwen/qwen3-32b": { maxTokens: 8192, contextWindow: 131072, @@ -90,25 +58,6 @@ export const groqModels = { outputPrice: 0.59, description: "Alibaba Qwen 3 32B model, 128K context.", }, - "deepseek-r1-distill-llama-70b": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.75, - outputPrice: 0.99, - description: "DeepSeek R1 Distill Llama 70B model, 128K context.", - }, - "moonshotai/kimi-k2-instruct": { - maxTokens: 16384, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: true, - inputPrice: 1.0, - outputPrice: 3.0, - cacheReadsPrice: 0.5, // 50% discount for cached input tokens - description: "Moonshot AI Kimi K2 Instruct 1T model, 128K context.", - }, "moonshotai/kimi-k2-instruct-0905": { maxTokens: 16384, contextWindow: 262144, 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/providers/minimax.ts b/packages/types/src/providers/minimax.ts index aac397bf2c..7152946f7f 100644 --- a/packages/types/src/providers/minimax.ts +++ b/packages/types/src/providers/minimax.ts @@ -15,6 +15,8 @@ export const minimaxModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + includedTools: ["search_and_replace"], + excludedTools: ["apply_diff"], preserveReasoning: true, inputPrice: 0.3, outputPrice: 1.2, @@ -30,6 +32,8 @@ export const minimaxModels = { supportsPromptCache: true, supportsNativeTools: true, defaultToolProtocol: "native", + includedTools: ["search_and_replace"], + excludedTools: ["apply_diff"], preserveReasoning: true, inputPrice: 0.3, outputPrice: 1.2, @@ -38,6 +42,23 @@ export const minimaxModels = { description: "MiniMax M2 Stable (High Concurrency, Commercial Use), a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.", }, + "MiniMax-M2.1": { + maxTokens: 16_384, + contextWindow: 192_000, + supportsImages: false, + supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", + includedTools: ["search_and_replace"], + excludedTools: ["apply_diff"], + preserveReasoning: true, + inputPrice: 0.3, + outputPrice: 1.2, + cacheWritesPrice: 0.375, + cacheReadsPrice: 0.03, + description: + "MiniMax M2.1 builds on M2 with improved overall performance for agentic coding tasks and significantly faster response times.", + }, } as const satisfies Record export const minimaxDefaultModelInfo: ModelInfo = minimaxModels[minimaxDefaultModelId] diff --git a/packages/types/src/providers/sambanova.ts b/packages/types/src/providers/sambanova.ts index 7c04475c60..dc592d180c 100644 --- a/packages/types/src/providers/sambanova.ts +++ b/packages/types/src/providers/sambanova.ts @@ -7,9 +7,7 @@ export type SambaNovaModelId = | "DeepSeek-R1" | "DeepSeek-V3-0324" | "DeepSeek-V3.1" - | "DeepSeek-R1-Distill-Llama-70B" | "Llama-4-Maverick-17B-128E-Instruct" - | "Llama-3.3-Swallow-70B-Instruct-v0.4" | "Qwen3-32B" | "gpt-oss-120b" @@ -72,15 +70,6 @@ export const sambaNovaModels = { outputPrice: 4.5, description: "DeepSeek V3.1 model with 32K context window.", }, - "DeepSeek-R1-Distill-Llama-70B": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.7, - outputPrice: 1.4, - description: "DeepSeek R1 distilled Llama 70B model with 128K context window.", - }, "Llama-4-Maverick-17B-128E-Instruct": { maxTokens: 8192, contextWindow: 131072, @@ -92,15 +81,6 @@ export const sambaNovaModels = { outputPrice: 1.8, description: "Meta Llama 4 Maverick 17B 128E Instruct model with 128K context window.", }, - "Llama-3.3-Swallow-70B-Instruct-v0.4": { - maxTokens: 8192, - contextWindow: 16384, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.6, - outputPrice: 1.2, - description: "Tokyotech Llama 3.3 Swallow 70B Instruct v0.4 model with 16K context window.", - }, "Qwen3-32B": { maxTokens: 8192, contextWindow: 8192, diff --git a/packages/types/src/providers/vercel-ai-gateway.ts b/packages/types/src/providers/vercel-ai-gateway.ts index 875b87bf8b..40d4f1ca50 100644 --- a/packages/types/src/providers/vercel-ai-gateway.ts +++ b/packages/types/src/providers/vercel-ai-gateway.ts @@ -90,6 +90,7 @@ export const vercelAiGatewayDefaultModelInfo: ModelInfo = { contextWindow: 200000, supportsImages: true, supportsPromptCache: true, + supportsNativeTools: true, inputPrice: 3, outputPrice: 15, cacheWritesPrice: 3.75, diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index cd247efb41..384b78de4d 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -477,6 +477,7 @@ export const vertexModels = { contextWindow: 131072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.35, outputPrice: 1.15, description: "Meta Llama 4 Maverick 17B Instruct model, 128K context.", @@ -486,6 +487,7 @@ export const vertexModels = { contextWindow: 163_840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 1.35, outputPrice: 5.4, description: "DeepSeek R1 (0528). Available in us-central1", @@ -495,6 +497,7 @@ export const vertexModels = { contextWindow: 163_840, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.6, outputPrice: 1.7, description: "DeepSeek V3.1. Available in us-west2", @@ -504,6 +507,7 @@ export const vertexModels = { contextWindow: 131_072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.15, outputPrice: 0.6, description: "OpenAI gpt-oss 120B. Available in us-central1", @@ -513,6 +517,7 @@ export const vertexModels = { contextWindow: 131_072, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.075, outputPrice: 0.3, description: "OpenAI gpt-oss 20B. Available in us-central1", @@ -522,6 +527,7 @@ export const vertexModels = { contextWindow: 262_144, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 1.0, outputPrice: 4.0, description: "Qwen3 Coder 480B A35B Instruct. Available in us-south1", @@ -531,6 +537,7 @@ export const vertexModels = { contextWindow: 262_144, supportsImages: false, supportsPromptCache: false, + supportsNativeTools: true, inputPrice: 0.25, outputPrice: 1.0, description: "Qwen3 235B A22B Instruct. Available in us-south1", diff --git a/packages/types/src/providers/zai.ts b/packages/types/src/providers/zai.ts index 1dfface9f6..93cf9bb23b 100644 --- a/packages/types/src/providers/zai.ts +++ b/packages/types/src/providers/zai.ts @@ -12,7 +12,7 @@ export type InternationalZAiModelId = keyof typeof internationalZAiModels export const internationalZAiDefaultModelId: InternationalZAiModelId = "glm-4.6" export const internationalZAiModels = { "glm-4.5": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -26,7 +26,7 @@ export const internationalZAiModels = { "GLM-4.5 is Zhipu's latest featured model. Its comprehensive capabilities in reasoning, coding, and agent reach the state-of-the-art (SOTA) level among open-source models, with a context length of up to 128k.", }, "glm-4.5-air": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -40,7 +40,7 @@ export const internationalZAiModels = { "GLM-4.5-Air is the lightweight version of GLM-4.5. It balances performance and cost-effectiveness, and can flexibly switch to hybrid thinking models.", }, "glm-4.5-x": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -54,7 +54,7 @@ export const internationalZAiModels = { "GLM-4.5-X is a high-performance variant optimized for strong reasoning with ultra-fast responses.", }, "glm-4.5-airx": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -67,7 +67,7 @@ export const internationalZAiModels = { description: "GLM-4.5-AirX is a lightweight, ultra-fast variant delivering strong performance with lower cost.", }, "glm-4.5-flash": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -94,7 +94,7 @@ export const internationalZAiModels = { "GLM-4.5V is Z.AI's multimodal visual reasoning model (image/video/text/file input), optimized for GUI tasks, grounding, and document/video understanding.", }, "glm-4.6": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 200_000, supportsImages: false, supportsPromptCache: true, @@ -107,8 +107,25 @@ export const internationalZAiModels = { description: "GLM-4.6 is Zhipu's newest model with an extended context window of up to 200k tokens, providing enhanced capabilities for processing longer documents and conversations.", }, + "glm-4.7": { + maxTokens: 16_384, + contextWindow: 200_000, + supportsImages: false, + supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", + supportsReasoningEffort: ["disable", "medium"], + reasoningEffort: "medium", + preserveReasoning: true, + inputPrice: 0.6, + outputPrice: 2.2, + cacheWritesPrice: 0, + cacheReadsPrice: 0.11, + description: + "GLM-4.7 is Zhipu's latest model with built-in thinking capabilities enabled by default. It provides enhanced reasoning for complex tasks while maintaining fast response times.", + }, "glm-4-32b-0414-128k": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: false, @@ -126,7 +143,7 @@ export type MainlandZAiModelId = keyof typeof mainlandZAiModels export const mainlandZAiDefaultModelId: MainlandZAiModelId = "glm-4.6" export const mainlandZAiModels = { "glm-4.5": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -140,7 +157,7 @@ export const mainlandZAiModels = { "GLM-4.5 is Zhipu's latest featured model. Its comprehensive capabilities in reasoning, coding, and agent reach the state-of-the-art (SOTA) level among open-source models, with a context length of up to 128k.", }, "glm-4.5-air": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -154,7 +171,7 @@ export const mainlandZAiModels = { "GLM-4.5-Air is the lightweight version of GLM-4.5. It balances performance and cost-effectiveness, and can flexibly switch to hybrid thinking models.", }, "glm-4.5-x": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -168,7 +185,7 @@ export const mainlandZAiModels = { "GLM-4.5-X is a high-performance variant optimized for strong reasoning with ultra-fast responses.", }, "glm-4.5-airx": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -181,7 +198,7 @@ export const mainlandZAiModels = { description: "GLM-4.5-AirX is a lightweight, ultra-fast variant delivering strong performance with lower cost.", }, "glm-4.5-flash": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 131_072, supportsImages: false, supportsPromptCache: true, @@ -208,7 +225,7 @@ export const mainlandZAiModels = { "GLM-4.5V is Z.AI's multimodal visual reasoning model (image/video/text/file input), optimized for GUI tasks, grounding, and document/video understanding.", }, "glm-4.6": { - maxTokens: 98_304, + maxTokens: 16_384, contextWindow: 204_800, supportsImages: false, supportsPromptCache: true, @@ -221,6 +238,23 @@ export const mainlandZAiModels = { description: "GLM-4.6 is Zhipu's newest model with an extended context window of up to 200k tokens, providing enhanced capabilities for processing longer documents and conversations.", }, + "glm-4.7": { + maxTokens: 16_384, + contextWindow: 204_800, + supportsImages: false, + supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", + supportsReasoningEffort: ["disable", "medium"], + reasoningEffort: "medium", + preserveReasoning: true, + inputPrice: 0.29, + outputPrice: 1.14, + cacheWritesPrice: 0, + cacheReadsPrice: 0.057, + description: + "GLM-4.7 is Zhipu's latest model with built-in thinking capabilities enabled by default. It provides enhanced reasoning for complex tasks while maintaining fast response times.", + }, } as const satisfies Record export const ZAI_DEFAULT_TEMPERATURE = 0.6 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 ae28f7654c..a4cddd93b4 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': @@ -474,6 +511,37 @@ importers: packages/config-typescript: {} + packages/core: + dependencies: + '@roo-code/types': + specifier: workspace:^ + version: link:../types + esbuild: + specifier: '>=0.25.0' + version: 0.25.9 + execa: + specifier: ^9.5.2 + version: 9.6.0 + openai: + specifier: ^5.12.2 + version: 5.12.2(ws@8.18.3)(zod@3.25.76) + zod: + specifier: ^3.25.61 + version: 3.25.76 + 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) + packages/evals: dependencies: '@roo-code/ipc': @@ -619,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': @@ -654,6 +737,9 @@ importers: '@roo-code/cloud': specifier: workspace:^ version: link:../packages/cloud + '@roo-code/core': + specifier: workspace:^ + version: link:../packages/core '@roo-code/ipc': specifier: workspace:^ version: link:../packages/ipc @@ -711,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 @@ -837,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 @@ -928,9 +1020,9 @@ importers: '@vscode/vsce': specifier: 3.3.2 version: 3.3.2 - esbuild: - specifier: '>=0.25.0' - version: 0.25.9 + esbuild-wasm: + specifier: ^0.25.0 + version: 0.25.12 execa: specifier: ^9.5.2 version: 9.5.3 @@ -4328,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'} @@ -4687,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==} @@ -5465,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==} @@ -5781,11 +5883,19 @@ 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: esbuild: '>=0.25.0' + esbuild-wasm@0.25.12: + resolution: {integrity: sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.25.9: resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} @@ -6355,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'} @@ -7480,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'} @@ -8826,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==} @@ -8947,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==} @@ -9646,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'} @@ -14141,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 @@ -14580,6 +14718,8 @@ snapshots: boolbase@1.0.0: {} + boolean@3.2.0: {} + bowser@2.11.0: {} brace-expansion@2.0.2: @@ -15367,6 +15507,8 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -15659,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) @@ -15666,6 +15810,8 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild-wasm@0.25.12: {} + esbuild@0.25.9: optionalDependencies: '@esbuild/aix-ppc64': 0.25.9 @@ -16418,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: {} @@ -17188,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: @@ -17527,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: {} @@ -17612,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: @@ -18254,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: @@ -18418,6 +18577,11 @@ snapshots: ws: 8.18.3 zod: 3.25.61 + openai@5.12.2(ws@8.18.3)(zod@3.25.76): + optionalDependencies: + ws: 8.18.3 + zod: 3.25.76 + option@0.2.4: {} optionator@0.9.4: @@ -18439,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: @@ -19362,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: @@ -19523,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 @@ -19845,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: {} @@ -20294,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: @@ -21078,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.0-release.png b/releases/3.37.0-release.png new file mode 100644 index 0000000000..2bee909c5b Binary files /dev/null and b/releases/3.37.0-release.png differ 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/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__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 0746602d7e..6890e4178b 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -1200,7 +1200,8 @@ describe("VertexHandler", () => { ) }) - it("should not include tools when toolProtocol is xml", async () => { + it("should include tools even when toolProtocol is set to xml (user preference now ignored)", async () => { + // XML protocol deprecation: user preference is now ignored when model supports native tools handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", vertexProjectId: "test-project", @@ -1241,9 +1242,14 @@ describe("VertexHandler", () => { // Just consume } + // Native is forced when supportsNativeTools===true, so tools should still be included expect(mockCreate).toHaveBeenCalledWith( - expect.not.objectContaining({ - tools: expect.anything(), + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: "get_weather", + }), + ]), }), undefined, ) diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 5c0c1632b4..3fa5baf81b 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -451,8 +451,8 @@ describe("AnthropicHandler", () => { ) }) - it("should not include tools when toolProtocol is xml", async () => { - // Create handler with xml tool protocol in options + it("should include tools even when toolProtocol is set to xml (user preference now ignored)", async () => { + // XML protocol deprecation: user preference is now ignored when model supports native tools const xmlHandler = new AnthropicHandler({ ...mockOptions, toolProtocol: "xml", @@ -468,9 +468,14 @@ describe("AnthropicHandler", () => { // Just consume } + // Native is forced when supportsNativeTools===true, so tools should still be included expect(mockCreate).toHaveBeenCalledWith( - expect.not.objectContaining({ - tools: expect.anything(), + expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: "get_weather", + }), + ]), }), expect.anything(), ) diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index 57ee649d6e..7d0d2548fc 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -383,4 +383,166 @@ describe("BaseOpenAiCompatibleProvider", () => { expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 50 }) }) }) + + describe("Tool call handling", () => { + it("should yield tool_call_end events when finish_reason is tool_calls", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_123", + function: { name: "test_tool", arguments: '{"arg":' }, + }, + ], + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '"value"}' }, + }, + ], + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have tool_call_partial and tool_call_end + const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + + expect(partialChunks).toHaveLength(2) + expect(endChunks).toHaveLength(1) + expect(endChunks[0]).toEqual({ type: "tool_call_end", id: "call_123" }) + }) + + it("should yield multiple tool_call_end events for parallel tool calls", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_001", + function: { name: "tool_a", arguments: "{}" }, + }, + { + index: 1, + id: "call_002", + function: { name: "tool_b", arguments: "{}" }, + }, + ], + }, + }, + ], + }, + }) + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + expect(endChunks).toHaveLength(2) + expect(endChunks.map((c: any) => c.id).sort()).toEqual(["call_001", "call_002"]) + }) + + it("should not yield tool_call_end when finish_reason is not tool_calls", async () => { + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + choices: [ + { + delta: { content: "Some text response" }, + finish_reason: "stop", + }, + ], + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) + + const stream = handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + expect(endChunks).toHaveLength(0) + }) + }) }) 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-native-tools.spec.ts b/src/api/providers/__tests__/bedrock-native-tools.spec.ts index 8325f94bfb..0396a81744 100644 --- a/src/api/providers/__tests__/bedrock-native-tools.spec.ts +++ b/src/api/providers/__tests__/bedrock-native-tools.spec.ts @@ -168,12 +168,13 @@ describe("AwsBedrockHandler Native Tool Calling", () => { expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)") // Second tool: line_ranges should be transformed from type: ["array", "null"] to anyOf + // with items moved inside the array variant (required by GPT-5-mini strict schema validation) const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any const lineRanges = readFileSchema.properties.files.items.properties.line_ranges - expect(lineRanges.anyOf).toEqual([{ type: "array" }, { type: "null" }]) + expect(lineRanges.anyOf).toEqual([{ type: "array", items: { type: "integer" } }, { type: "null" }]) expect(lineRanges.type).toBeUndefined() - // items also gets additionalProperties: false from normalization - expect(lineRanges.items.type).toBe("integer") + // items should now be inside the array variant, not at root + expect(lineRanges.items).toBeUndefined() expect(lineRanges.description).toBe("Optional line ranges") }) diff --git a/src/api/providers/__tests__/featherless.spec.ts b/src/api/providers/__tests__/featherless.spec.ts index 0051882885..936c10fcd0 100644 --- a/src/api/providers/__tests__/featherless.spec.ts +++ b/src/api/providers/__tests__/featherless.spec.ts @@ -3,12 +3,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { - type FeatherlessModelId, - featherlessDefaultModelId, - featherlessModels, - DEEP_SEEK_DEFAULT_TEMPERATURE, -} from "@roo-code/types" +import { type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" import { FeatherlessHandler } from "../featherless" @@ -76,7 +71,7 @@ describe("FeatherlessHandler", () => { expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: featherlessApiKey })) }) - it("should handle DeepSeek R1 reasoning format", async () => { + it("should handle reasoning format from models that use tags", async () => { // Override the mock for this specific test mockCreate.mockImplementationOnce(async () => ({ [Symbol.asyncIterator]: async function* () { @@ -113,7 +108,7 @@ describe("FeatherlessHandler", () => { const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] vi.spyOn(handler, "getModel").mockReturnValue({ - id: "deepseek-ai/DeepSeek-R1-0528", + id: "some-reasoning-model", info: { maxTokens: 1024, temperature: 0.7 }, } as any) @@ -154,7 +149,7 @@ describe("FeatherlessHandler", () => { }) it("should return specified model when valid model is provided", () => { - const testModelId: FeatherlessModelId = "deepseek-ai/DeepSeek-R1-0528" + const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" const handlerWithModel = new FeatherlessHandler({ apiModelId: testModelId, featherlessApiKey: "test-featherless-api-key", @@ -225,8 +220,8 @@ describe("FeatherlessHandler", () => { expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) }) - it("createMessage should pass correct parameters to Featherless client for DeepSeek R1", async () => { - const modelId: FeatherlessModelId = "deepseek-ai/DeepSeek-R1-0528" + it("createMessage should pass correct parameters to Featherless client", async () => { + const modelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" // Clear previous mocks and set up new implementation mockCreate.mockClear() @@ -247,27 +242,9 @@ describe("FeatherlessHandler", () => { const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) await messageGenerator.next() - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - messages: [ - { - role: "user", - content: `${systemPrompt}\n${messages[0].content}`, - }, - ], - }), - ) - }) - - it("should apply DeepSeek default temperature for R1 models", () => { - const testModelId: FeatherlessModelId = "deepseek-ai/DeepSeek-R1-0528" - const handlerWithModel = new FeatherlessHandler({ - apiModelId: testModelId, - featherlessApiKey: "test-featherless-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) + expect(mockCreate).toHaveBeenCalled() + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.model).toBe(modelId) }) it("should use default temperature for non-DeepSeek models", () => { 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__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 31fdaa2389..4469efd4d1 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -295,6 +295,10 @@ describe("OpenAiHandler", () => { name: undefined, arguments: '"value"}', }) + + // Verify tool_call_end event is emitted when finish_reason is "tool_calls" + const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + expect(toolCallEndChunks).toHaveLength(1) }) it("should yield tool calls even when finish_reason is not set (fallback behavior)", async () => { @@ -855,6 +859,10 @@ describe("OpenAiHandler", () => { name: undefined, arguments: "{}", }) + + // Verify tool_call_end event is emitted when finish_reason is "tool_calls" + const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + expect(toolCallEndChunks).toHaveLength(1) }) it("should yield tool calls for O3 model even when finish_reason is not set (fallback behavior)", async () => { 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/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 707abee09f..34323b108d 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -82,6 +82,22 @@ describe("ZAiHandler", () => { expect(model.info.contextWindow).toBe(200_000) }) + it("should return GLM-4.7 international model with thinking support", () => { + const testModelId: InternationalZAiModelId = "glm-4.7" + const handlerWithModel = new ZAiHandler({ + apiModelId: testModelId, + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(internationalZAiModels[testModelId]) + expect(model.info.contextWindow).toBe(200_000) + expect(model.info.supportsReasoningEffort).toEqual(["disable", "medium"]) + expect(model.info.reasoningEffort).toBe("medium") + expect(model.info.preserveReasoning).toBe(true) + }) + it("should return GLM-4.5v international model with vision support", () => { const testModelId: InternationalZAiModelId = "glm-4.5v" const handlerWithModel = new ZAiHandler({ @@ -161,6 +177,22 @@ describe("ZAiHandler", () => { expect(model.info.maxTokens).toBe(16_384) expect(model.info.contextWindow).toBe(131_072) }) + + it("should return GLM-4.7 China model with thinking support", () => { + const testModelId: MainlandZAiModelId = "glm-4.7" + const handlerWithModel = new ZAiHandler({ + apiModelId: testModelId, + zaiApiKey: "test-zai-api-key", + zaiApiLine: "china_coding", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(mainlandZAiModels[testModelId]) + expect(model.info.contextWindow).toBe(204_800) + expect(model.info.supportsReasoningEffort).toEqual(["disable", "medium"]) + expect(model.info.reasoningEffort).toBe("medium") + expect(model.info.preserveReasoning).toBe(true) + }) }) describe("International API", () => { @@ -371,4 +403,123 @@ describe("ZAiHandler", () => { ) }) }) + + describe("GLM-4.7 Thinking Mode", () => { + it("should enable thinking by default for GLM-4.7 (default reasoningEffort is medium)", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-4.7", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + // No reasoningEffort setting - should use model default (medium) + }) + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + const messageGenerator = handlerWithModel.createMessage("system prompt", []) + await messageGenerator.next() + + // For GLM-4.7 with default reasoning (medium), thinking should be enabled + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "glm-4.7", + thinking: { type: "enabled" }, + }), + ) + }) + + it("should disable thinking for GLM-4.7 when reasoningEffort is set to disable", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-4.7", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + enableReasoningEffort: true, + reasoningEffort: "disable", + }) + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + const messageGenerator = handlerWithModel.createMessage("system prompt", []) + await messageGenerator.next() + + // For GLM-4.7 with reasoning disabled, thinking should be disabled + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "glm-4.7", + thinking: { type: "disabled" }, + }), + ) + }) + + it("should enable thinking for GLM-4.7 when reasoningEffort is set to medium", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-4.7", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + enableReasoningEffort: true, + reasoningEffort: "medium", + }) + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + const messageGenerator = handlerWithModel.createMessage("system prompt", []) + await messageGenerator.next() + + // For GLM-4.7 with reasoning set to medium, thinking should be enabled + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "glm-4.7", + thinking: { type: "enabled" }, + }), + ) + }) + + it("should NOT add thinking parameter for non-thinking models like GLM-4.6", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-4.6", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) + + const messageGenerator = handlerWithModel.createMessage("system prompt", []) + await messageGenerator.next() + + // For GLM-4.6 (no thinking support), thinking parameter should not be present + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.thinking).toBeUndefined() + }) + }) }) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 92b9558c45..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, @@ -129,6 +129,7 @@ export abstract class BaseOpenAiCompatibleProvider ) let lastUsage: OpenAI.CompletionUsage | undefined + const activeToolCallIds = new Set() for await (const chunk of stream) { // Check for provider-specific error responses (e.g., MiniMax base_resp) @@ -140,6 +141,7 @@ export abstract class BaseOpenAiCompatibleProvider } const delta = chunk.choices?.[0]?.delta + const finishReason = chunk.choices?.[0]?.finish_reason if (delta?.content) { for (const processedChunk of matcher.update(delta.content)) { @@ -162,6 +164,9 @@ export abstract class BaseOpenAiCompatibleProvider // Emit raw tool call chunks - NativeToolCallParser handles state management if (delta?.tool_calls) { for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + activeToolCallIds.add(toolCall.id) + } yield { type: "tool_call_partial", index: toolCall.index, @@ -172,6 +177,15 @@ export abstract class BaseOpenAiCompatibleProvider } } + // Emit tool_call_end events when finish_reason is "tool_calls" + // This ensures tool calls are finalized even if the stream doesn't properly close + if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { + for (const id of activeToolCallIds) { + yield { type: "tool_call_end", id } + } + activeToolCallIds.clear() + } + if (chunk.usage) { lastUsage = chunk.usage } diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 84c8cf6fe9..a6adeeadbd 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -5,6 +5,7 @@ import type { ModelInfo } from "@roo-code/types" import type { ApiHandler, ApiHandlerCreateMessageMetadata } from "../index" import { ApiStream } from "../transform/stream" import { countTokens } from "../../utils/countTokens" +import { isMcpTool } from "../../utils/mcp-name" /** * Base class for API providers that implements common functionality. @@ -28,24 +29,33 @@ export abstract class BaseProvider implements ApiHandler { return undefined } - return tools.map((tool) => - tool.type === "function" - ? { - ...tool, - function: { - ...tool.function, - strict: true, - parameters: this.convertToolSchemaForOpenAI(tool.function.parameters), - }, - } - : tool, - ) + return tools.map((tool) => { + if (tool.type !== "function") { + return tool + } + + // MCP tools use the 'mcp--' prefix - disable strict mode for them + // to preserve optional parameters from the MCP server schema + const isMcp = isMcpTool(tool.function.name) + + return { + ...tool, + function: { + ...tool.function, + strict: !isMcp, + parameters: isMcp + ? tool.function.parameters + : this.convertToolSchemaForOpenAI(tool.function.parameters), + }, + } + }) } /** * 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 @@ -57,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/fetchers/__tests__/chutes.spec.ts b/src/api/providers/fetchers/__tests__/chutes.spec.ts index b68903fde5..79ed027383 100644 --- a/src/api/providers/fetchers/__tests__/chutes.spec.ts +++ b/src/api/providers/fetchers/__tests__/chutes.spec.ts @@ -212,4 +212,132 @@ describe("getChutesModels", () => { expect(models["test/no-tools-model"].supportsNativeTools).toBe(false) expect(models["test/no-tools-model"].defaultToolProtocol).toBeUndefined() }) + + it("should skip empty objects in API response and still process valid models", async () => { + const mockResponse = { + data: { + data: [ + { + id: "test/valid-model", + object: "model", + owned_by: "test", + created: 1234567890, + context_length: 128000, + max_model_len: 8192, + input_modalities: ["text"], + }, + {}, // Empty object - should be skipped + { + id: "test/another-valid-model", + object: "model", + context_length: 64000, + max_model_len: 4096, + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const models = await getChutesModels("test-api-key") + + // Valid models should be processed + expect(models["test/valid-model"]).toBeDefined() + expect(models["test/valid-model"].contextWindow).toBe(128000) + expect(models["test/another-valid-model"]).toBeDefined() + expect(models["test/another-valid-model"].contextWindow).toBe(64000) + }) + + it("should skip models without id field", async () => { + const mockResponse = { + data: { + data: [ + { + // Missing id field + object: "model", + context_length: 128000, + max_model_len: 8192, + }, + { + id: "test/valid-model", + context_length: 64000, + max_model_len: 4096, + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const models = await getChutesModels("test-api-key") + + // Only the valid model should be added + expect(models["test/valid-model"]).toBeDefined() + // Hardcoded models should still exist + expect(Object.keys(models).length).toBeGreaterThan(1) + }) + + it("should calculate maxTokens fallback when max_model_len is missing", async () => { + const mockResponse = { + data: { + data: [ + { + id: "test/no-max-len-model", + object: "model", + context_length: 100000, + // max_model_len is missing + input_modalities: ["text"], + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const models = await getChutesModels("test-api-key") + + // Should calculate maxTokens as 20% of contextWindow + expect(models["test/no-max-len-model"]).toBeDefined() + expect(models["test/no-max-len-model"].maxTokens).toBe(20000) // 100000 * 0.2 + expect(models["test/no-max-len-model"].contextWindow).toBe(100000) + }) + + it("should gracefully handle response with mixed valid and invalid items", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const mockResponse = { + data: { + data: [ + { + id: "test/valid-1", + context_length: 128000, + max_model_len: 8192, + }, + {}, // Empty - will be skipped + null, // Null - will be skipped + { + id: "", // Empty string id - will be skipped + context_length: 64000, + }, + { + id: "test/valid-2", + context_length: 256000, + max_model_len: 16384, + supported_features: ["tools"], + }, + ], + }, + } + + mockedAxios.get.mockResolvedValue(mockResponse) + + const models = await getChutesModels("test-api-key") + + // Both valid models should be processed + expect(models["test/valid-1"]).toBeDefined() + expect(models["test/valid-2"]).toBeDefined() + expect(models["test/valid-2"].supportsNativeTools).toBe(true) + + consoleErrorSpy.mockRestore() + }) }) diff --git a/src/api/providers/fetchers/chutes.ts b/src/api/providers/fetchers/chutes.ts index d5334aa59b..247d8f3c55 100644 --- a/src/api/providers/fetchers/chutes.ts +++ b/src/api/providers/fetchers/chutes.ts @@ -6,19 +6,22 @@ import { type ModelInfo, chutesModels } from "@roo-code/types" import { DEFAULT_HEADERS } from "../constants" // Chutes models endpoint follows OpenAI /models shape with additional fields. +// All fields are optional to allow graceful handling of incomplete API responses. const ChutesModelSchema = z.object({ - id: z.string(), + id: z.string().optional(), object: z.literal("model").optional(), owned_by: z.string().optional(), created: z.number().optional(), context_length: z.number().optional(), - max_model_len: z.number(), + max_model_len: z.number().optional(), input_modalities: z.array(z.string()).optional(), supported_features: z.array(z.string()).optional(), }) const ChutesModelsResponseSchema = z.object({ data: z.array(ChutesModelSchema) }) +type ChutesModelsResponse = z.infer + export async function getChutesModels(apiKey?: string): Promise> { const headers: Record = { ...DEFAULT_HEADERS } @@ -32,33 +35,49 @@ export async function getChutesModels(apiKey?: string): Promise = { ...chutesModels } try { - const response = await axios.get(url, { headers }) - const parsed = ChutesModelsResponseSchema.safeParse(response.data) + const response = await axios.get(url, { headers }) + const result = ChutesModelsResponseSchema.safeParse(response.data) - if (parsed.success) { - for (const m of parsed.data.data) { - const contextWindow = m.context_length + // Graceful fallback: use parsed data if valid, otherwise fall back to raw response data. + // This mirrors the OpenRouter pattern for handling API responses with some invalid items. + const data = result.success ? result.data.data : response.data?.data - if (!contextWindow) { - continue - } + if (!result.success) { + console.error(`Error parsing Chutes models response: ${JSON.stringify(result.error.format(), null, 2)}`) + } - const info: ModelInfo = { - maxTokens: m.max_model_len, - contextWindow, - supportsImages: (m.input_modalities || []).includes("image"), - supportsPromptCache: false, - supportsNativeTools: (m.supported_features || []).includes("tools"), - inputPrice: 0, - outputPrice: 0, - description: `Chutes AI model: ${m.id}`, - } + if (!data || !Array.isArray(data)) { + console.error("Chutes models response missing data array") + return models + } - // Union: dynamic models override hardcoded ones if they have the same ID. - models[m.id] = info + for (const m of data) { + // Skip items missing required fields (e.g., empty objects from API) + if (!m || typeof m.id !== "string" || !m.id) { + continue } - } else { - console.error(`Error parsing Chutes models: ${JSON.stringify(parsed.error.format(), null, 2)}`) + + const contextWindow = typeof m.context_length === "number" && Number.isFinite(m.context_length) ? m.context_length : undefined + const maxModelLen = typeof m.max_model_len === "number" && Number.isFinite(m.max_model_len) ? m.max_model_len : undefined + + // Skip models without valid context window information + if (!contextWindow) { + continue + } + + const info: ModelInfo = { + maxTokens: maxModelLen ?? Math.ceil(contextWindow * 0.2), + contextWindow, + supportsImages: (m.input_modalities || []).includes("image"), + supportsPromptCache: false, + supportsNativeTools: (m.supported_features || []).includes("tools"), + inputPrice: 0, + outputPrice: 0, + description: `Chutes AI model: ${m.id}`, + } + + // Union: dynamic models override hardcoded ones if they have the same ID. + models[m.id] = info } } catch (error) { console.error(`Error fetching Chutes models: ${error instanceof Error ? error.message : String(error)}`) 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/lite-llm.ts b/src/api/providers/lite-llm.ts index 9acbc35d07..34341a8830 100644 --- a/src/api/providers/lite-llm.ts +++ b/src/api/providers/lite-llm.ts @@ -133,7 +133,6 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa }, ...(useNativeTools && { tools: this.convertToolsForOpenAI(metadata.tools) }), ...(useNativeTools && metadata.tool_choice && { tool_choice: metadata.tool_choice }), - ...(useNativeTools && { parallel_tool_calls: metadata?.parallelToolCalls ?? false }), } // GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 12d7934546..a7cea478ed 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -9,6 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -87,15 +88,26 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand // MiniMax M2 models support prompt caching const supportsPromptCache = info.supportsPromptCache ?? false + // Merge environment_details from messages that follow tool_result blocks + // into the tool_result content. This preserves reasoning continuity for + // thinking models by preventing user messages from interrupting the + // reasoning context after tool use (similar to r1-format's mergeToolResultText). + const processedMessages = mergeEnvironmentDetailsForMiniMax(messages) + + // Build the system blocks array + const systemBlocks: Anthropic.Messages.TextBlockParam[] = [ + supportsPromptCache + ? { text: systemPrompt, type: "text", cache_control: cacheControl } + : { text: systemPrompt, type: "text" }, + ] + // Prepare request parameters const requestParams: Anthropic.Messages.MessageCreateParams = { model: modelId, max_tokens: maxTokens ?? 16_384, temperature: temperature ?? 1.0, - system: supportsPromptCache - ? [{ text: systemPrompt, type: "text", cache_control: cacheControl }] - : [{ text: systemPrompt, type: "text" }], - messages: supportsPromptCache ? this.addCacheControl(messages, cacheControl) : messages, + system: systemBlocks, + messages: supportsPromptCache ? this.addCacheControl(processedMessages, cacheControl) : processedMessages, stream: true, } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 762b81fc83..58a62497f7 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -24,6 +24,7 @@ import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { isMcpTool } from "../../utils/mcp-name" export type OpenAiNativeModel = ReturnType @@ -195,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 @@ -218,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). @@ -291,13 +334,21 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio ...(metadata?.tools && { tools: metadata.tools .filter((tool) => tool.type === "function") - .map((tool) => ({ - type: "function", - name: tool.function.name, - description: tool.function.description, - parameters: ensureAllRequired(tool.function.parameters), - strict: true, - })), + .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 + ? ensureAdditionalPropertiesFalse(tool.function.parameters) + : ensureAllRequired(tool.function.parameters), + strict: !isMcp, + } + }), }), ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index b198fe11d3..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" @@ -194,9 +195,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ) let lastUsage + const activeToolCallIds = new Set() for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} + const finishReason = chunk.choices?.[0]?.finish_reason if (delta.content) { for (const chunk of matcher.update(delta.content)) { @@ -211,17 +214,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - if (delta.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } + yield* this.processToolCalls(delta, finishReason, activeToolCallIds) if (chunk.usage) { lastUsage = chunk.usage @@ -299,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 } } @@ -443,8 +442,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } private async *handleStreamResponse(stream: AsyncIterable): ApiStream { + const activeToolCallIds = new Set() + for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta + const finishReason = chunk.choices?.[0]?.finish_reason if (delta) { if (delta.content) { @@ -454,18 +456,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta.tool_calls) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } + yield* this.processToolCalls(delta, finishReason, activeToolCallIds) } if (chunk.usage) { @@ -478,6 +469,46 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + /** + * Helper generator to process tool calls from a stream chunk. + * Tracks active tool call IDs and yields tool_call_partial and tool_call_end events. + * @param delta - The delta object from the stream chunk + * @param finishReason - The finish_reason from the stream chunk + * @param activeToolCallIds - Set to track active tool call IDs (mutated in place) + */ + private *processToolCalls( + delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta | undefined, + finishReason: string | null | undefined, + activeToolCallIds: Set, + ): Generator< + | { type: "tool_call_partial"; index: number; id?: string; name?: string; arguments?: string } + | { type: "tool_call_end"; id: string } + > { + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + activeToolCallIds.add(toolCall.id) + } + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + + // Emit tool_call_end events when finish_reason is "tool_calls" + // This ensures tool calls are finalized even if the stream doesn't properly close + if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { + for (const id of activeToolCallIds) { + yield { type: "tool_call_end", id } + } + activeToolCallIds.clear() + } + } + protected _getUrlHost(baseUrl?: string): string { try { return new URL(baseUrl ?? "").host 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 83eab87ef7..bfd99750bf 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -140,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, { @@ -155,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 @@ -179,7 +185,10 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) { for (const detail of deltaWithReasoning.reasoning_details) { const index = detail.index ?? 0 - const key = `${detail.type}-${index}` + // Use id as key when available to merge chunks that share the same reasoning block id + // This ensures that reasoning.summary and reasoning.encrypted chunks with the same id + // are merged into a single object, matching the provider's expected format + const key = detail.id ?? `${detail.type}-${index}` const existing = reasoningDetailsAccumulator.get(key) if (existing) { @@ -194,6 +203,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider { existing.data = (existing.data || "") + detail.data } // Update other fields if provided + // Note: Don't update type - keep original type (e.g., reasoning.summary) + // even when encrypted data chunks arrive with type reasoning.encrypted if (detail.id !== undefined) existing.id = detail.id if (detail.format !== undefined) existing.format = detail.format if (detail.signature !== undefined) existing.signature = detail.signature @@ -212,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 } } } @@ -271,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/providers/zai.ts b/src/api/providers/zai.ts index 25074f5f0b..c7bf6d635e 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -1,3 +1,6 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + import { internationalZAiModels, mainlandZAiModels, @@ -8,10 +11,17 @@ import { zaiApiLineConfigs, } from "@roo-code/types" -import type { ApiHandlerOptions } from "../../shared/api" +import { type ApiHandlerOptions, getModelMaxOutputTokens, shouldUseReasoningEffort } from "../../shared/api" +import { convertToZAiFormat } from "../transform/zai-format" +import type { ApiHandlerCreateMessageMetadata } from "../index" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +// Custom interface for Z.ai params to support thinking mode +type ZAiChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & { + thinking?: { type: "enabled" | "disabled" } +} + export class ZAiHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { const isChina = zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].isChina @@ -28,4 +38,76 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider { defaultTemperature: ZAI_DEFAULT_TEMPERATURE, }) } + + /** + * Override createStream to handle GLM-4.7's thinking mode. + * GLM-4.7 has thinking enabled by default in the API, so we need to + * explicitly send { type: "disabled" } when the user turns off reasoning. + */ + protected override createStream( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + requestOptions?: OpenAI.RequestOptions, + ) { + const { id: modelId, info } = this.getModel() + + // Check if this is a GLM-4.7 model with thinking support + const isThinkingModel = modelId === "glm-4.7" && Array.isArray(info.supportsReasoningEffort) + + if (isThinkingModel) { + // For GLM-4.7, thinking is ON by default in the API. + // We need to explicitly disable it when reasoning is off. + const useReasoning = shouldUseReasoningEffort({ model: info, settings: this.options }) + + // Create the stream with our custom thinking parameter + return this.createStreamWithThinking(systemPrompt, messages, metadata, useReasoning) + } + + // For non-thinking models, use the default behavior + return super.createStream(systemPrompt, messages, metadata, requestOptions) + } + + /** + * Creates a stream with explicit thinking control for GLM-4.7 + */ + private createStreamWithThinking( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + useReasoning?: boolean, + ) { + const { id: model, info } = this.getModel() + + const max_tokens = + getModelMaxOutputTokens({ + modelId: model, + model: info, + settings: this.options, + format: "openai", + }) ?? undefined + + const temperature = this.options.modelTemperature ?? this.defaultTemperature + + // Use Z.ai format to preserve reasoning_content and merge post-tool text into tool messages + const convertedMessages = convertToZAiFormat(messages, { mergeToolResultText: true }) + + const params: ZAiChatCompletionParams = { + model, + max_tokens, + temperature, + messages: [{ role: "system", content: systemPrompt }, ...convertedMessages], + stream: true, + stream_options: { include_usage: true }, + // For GLM-4.7: thinking is ON by default, so we explicitly disable when needed + thinking: useReasoning ? { type: "enabled" } : { type: "disabled" }, + ...(metadata?.tools && { tools: this.convertToolsForOpenAI(metadata.tools) }), + ...(metadata?.tool_choice && { tool_choice: metadata.tool_choice }), + ...(metadata?.toolProtocol === "native" && { + parallel_tool_calls: metadata.parallelToolCalls ?? false, + }), + } + + return this.client.chat.completions.create(params) + } } diff --git a/src/api/transform/__tests__/minimax-format.spec.ts b/src/api/transform/__tests__/minimax-format.spec.ts new file mode 100644 index 0000000000..271dfb5105 --- /dev/null +++ b/src/api/transform/__tests__/minimax-format.spec.ts @@ -0,0 +1,336 @@ +// npx vitest run api/transform/__tests__/minimax-format.spec.ts + +import { Anthropic } from "@anthropic-ai/sdk" + +import { mergeEnvironmentDetailsForMiniMax } from "../minimax-format" + +describe("mergeEnvironmentDetailsForMiniMax", () => { + it("should pass through simple text messages unchanged", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: "Hi there!", + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + expect(result).toHaveLength(2) + expect(result).toEqual(messages) + }) + + it("should pass through user messages with only tool_result blocks unchanged", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + ], + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + expect(result).toHaveLength(1) + expect(result).toEqual(messages) + }) + + it("should pass through user messages with only text blocks unchanged", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "Some user message", + }, + ], + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + expect(result).toHaveLength(1) + expect(result).toEqual(messages) + }) + + it("should merge text content into last tool_result when both tool_result AND text blocks exist", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + { + type: "text", + text: "\nCurrent Time: 2024-01-01\n", + }, + ], + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + // The message should have only tool_result with merged content + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[] + expect(content).toHaveLength(1) + expect(content[0].type).toBe("tool_result") + expect(content[0].tool_use_id).toBe("tool-123") + expect(content[0].content).toBe( + "Tool result content\n\n\nCurrent Time: 2024-01-01\n", + ) + }) + + it("should merge multiple text blocks into last tool_result", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result 1", + }, + { + type: "text", + text: "First text block", + }, + { + type: "tool_result", + tool_use_id: "tool-456", + content: "Tool result 2", + }, + { + type: "text", + text: "Second text block", + }, + ], + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + // The message should have only tool_result blocks, with text merged into the last one + expect(result).toHaveLength(1) + const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[] + expect(content).toHaveLength(2) + expect(content[0].type).toBe("tool_result") + expect(content[0].content).toBe("Tool result 1") // First one unchanged + expect(content[1].type).toBe("tool_result") + expect(content[1].content).toBe("Tool result 2\n\nFirst text block\n\nSecond text block") // Second has merged text + }) + + it("should NOT merge text when images are present (cannot move images to tool_result)", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + { + type: "text", + text: "Some text", + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "base64data", + }, + }, + ], + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + // Message should be unchanged since images are present + expect(result).toHaveLength(1) + expect(result).toEqual(messages) + }) + + it("should pass through assistant messages unchanged", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "I will help you with that.", + }, + { + type: "tool_use", + id: "tool-123", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + expect(result).toHaveLength(1) + expect(result).toEqual(messages) + }) + + it("should handle mixed conversation with merging only for eligible messages", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Create a file", + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "I'll create the file.", + }, + { + type: "tool_use", + id: "tool-123", + name: "write_file", + input: { path: "test.ts", content: "// test" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "File created successfully", + }, + { + type: "text", + text: "\nCurrent Time: 2024-01-01\n", + }, + ], + }, + { + role: "assistant", + content: "The file has been created.", + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + // Should have all 4 messages + expect(result).toHaveLength(4) + + // First user message unchanged (simple string) + expect(result[0]).toEqual(messages[0]) + + // Assistant message unchanged + expect(result[1]).toEqual(messages[1]) + + // Third message should have tool_result with merged environment_details + const thirdMessage = result[2].content as Anthropic.Messages.ToolResultBlockParam[] + expect(thirdMessage).toHaveLength(1) + expect(thirdMessage[0].type).toBe("tool_result") + expect(thirdMessage[0].content).toContain("File created successfully") + expect(thirdMessage[0].content).toContain("environment_details") + + // Fourth message unchanged + expect(result[3]).toEqual(messages[3]) + }) + + it("should handle string content in user messages", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Just a string message", + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + expect(result).toHaveLength(1) + expect(result).toEqual(messages) + }) + + it("should handle empty messages array", () => { + const messages: Anthropic.Messages.MessageParam[] = [] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + expect(result).toHaveLength(0) + }) + + it("should handle tool_result with array content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: [ + { type: "text", text: "Part 1" }, + { type: "text", text: "Part 2" }, + ], + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + expect(result).toHaveLength(1) + const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[] + expect(content).toHaveLength(1) + expect(content[0].type).toBe("tool_result") + // Array content should be concatenated and then merged with text + expect(content[0].content).toBe("Part 1\nPart 2\n\nContext") + }) + + it("should handle tool_result with empty content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "", + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const result = mergeEnvironmentDetailsForMiniMax(messages) + + expect(result).toHaveLength(1) + const content = result[0].content as Anthropic.Messages.ToolResultBlockParam[] + expect(content).toHaveLength(1) + expect(content[0].type).toBe("tool_result") + expect(content[0].content).toBe("Context") + }) +}) diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts index da9329fa32..2e7f61c9f3 100644 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ b/src/api/transform/__tests__/openai-format.spec.ts @@ -224,4 +224,548 @@ describe("convertToOpenAiMessages", () => { const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123") }) + + describe("mergeToolResultText option", () => { + it("should merge text content into last tool message when mergeToolResultText is true", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + { + type: "text", + text: "\nSome context\n", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) + + // Should produce only one tool message with merged content + expect(openAiMessages).toHaveLength(1) + const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage.role).toBe("tool") + expect(toolMessage.tool_call_id).toBe("tool-123") + expect(toolMessage.content).toBe( + "Tool result content\n\n\nSome context\n", + ) + }) + + it("should merge text into last tool message when multiple tool results exist", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_1", + content: "First result", + }, + { + type: "tool_result", + tool_use_id: "call_2", + content: "Second result", + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) + + // Should produce two tool messages, with text merged into the last one + expect(openAiMessages).toHaveLength(2) + expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe("First result") + expect((openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( + "Second result\n\nContext", + ) + }) + + it("should NOT merge text when images are present (fall back to user message)", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + { + type: "image", + source: { + type: "base64", + media_type: "image/png", + data: "base64data", + }, + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) + + // Should produce a tool message AND a user message (because image is present) + expect(openAiMessages).toHaveLength(2) + expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") + expect(openAiMessages[1].role).toBe("user") + }) + + it("should create separate user message when mergeToolResultText is false", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-123", + content: "Tool result content", + }, + { + type: "text", + text: "\nSome context\n", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: false }) + + // Should produce a tool message AND a separate user message (default behavior) + expect(openAiMessages).toHaveLength(2) + expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") + expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( + "Tool result content", + ) + expect(openAiMessages[1].role).toBe("user") + }) + + it("should work with normalizeToolCallId when mergeToolResultText is true", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_5019f900a247472bacde0b82", + content: "Tool result content", + }, + { + type: "text", + text: "Context", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { + mergeToolResultText: true, + normalizeToolCallId: normalizeMistralToolCallId, + }) + + // Should merge AND normalize the ID + expect(openAiMessages).toHaveLength(1) + const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(toolMessage.role).toBe("tool") + expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) + expect(toolMessage.content).toBe( + "Tool result content\n\nContext", + ) + }) + + it("should handle user messages with only text content (no tool results)", () => { + const anthropicMessages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text", + text: "Hello, how are you?", + }, + ], + }, + ] + + const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) + + // Should produce a normal user message + expect(openAiMessages).toHaveLength(1) + 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/minimax-format.ts b/src/api/transform/minimax-format.ts new file mode 100644 index 0000000000..32a32a4437 --- /dev/null +++ b/src/api/transform/minimax-format.ts @@ -0,0 +1,118 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +type ContentBlock = Anthropic.Messages.ContentBlockParam + +/** + * Merges text content (like environment_details) that follows tool_result blocks + * into the last tool_result's content. This preserves reasoning continuity for + * thinking models by avoiding separate user messages after tool results. + * + * Key behavior: + * - User messages with ONLY tool_result blocks: keep as-is + * - User messages with ONLY text/image: keep as-is + * - User messages with tool_result blocks AND text blocks: merge the text blocks + * into the last tool_result's content + * + * @param messages Array of Anthropic messages + * @returns Modified messages with text merged into tool_result content + */ +export function mergeEnvironmentDetailsForMiniMax( + messages: Anthropic.Messages.MessageParam[], +): Anthropic.Messages.MessageParam[] { + const result: Anthropic.Messages.MessageParam[] = [] + + for (const message of messages) { + if (message.role === "user") { + if (typeof message.content === "string") { + // Simple string content - keep as-is + result.push(message) + } else if (Array.isArray(message.content)) { + // Check if this message has both tool_result blocks and text blocks + const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] + const textBlocks: Anthropic.Messages.TextBlockParam[] = [] + const imageBlocks: Anthropic.Messages.ImageBlockParam[] = [] + + for (const block of message.content) { + if (block.type === "tool_result") { + toolResultBlocks.push(block) + } else if (block.type === "text") { + textBlocks.push(block) + } else if (block.type === "image") { + imageBlocks.push(block) + } + } + + // If we have tool_result blocks AND text blocks (like environment_details), + // merge the text into the last tool_result's content + const hasToolResults = toolResultBlocks.length > 0 + const hasTextBlocks = textBlocks.length > 0 + const hasImageBlocks = imageBlocks.length > 0 + + if (hasToolResults && hasTextBlocks && !hasImageBlocks) { + // Merge text content into the last tool_result + const textContent = textBlocks.map((b) => b.text).join("\n\n") + const modifiedToolResults = [...toolResultBlocks] + const lastToolResult = modifiedToolResults[modifiedToolResults.length - 1] + + // Get existing content as string + let existingContent: string + if (typeof lastToolResult.content === "string") { + existingContent = lastToolResult.content + } else if (Array.isArray(lastToolResult.content)) { + existingContent = + lastToolResult.content + ?.map((c) => { + if (c.type === "text") return c.text + if (c.type === "image") return "(image)" + return "" + }) + .join("\n") ?? "" + } else { + existingContent = "" + } + + // Merge text into the last tool_result + modifiedToolResults[modifiedToolResults.length - 1] = { + ...lastToolResult, + content: existingContent ? `${existingContent}\n\n${textContent}` : textContent, + } + + result.push({ + ...message, + content: modifiedToolResults as ContentBlock[], + }) + } else { + // Keep the message as-is if: + // - Only tool_result blocks (no text to merge) + // - Only text/image blocks (no tool results) + // - Has images (can't merge into tool_result) + result.push(message) + } + } else { + // Unknown format - keep as-is + result.push(message) + } + } else { + // Assistant messages - keep as-is + result.push(message) + } + } + + return result +} + +/** + * @deprecated Use mergeEnvironmentDetailsForMiniMax instead. This function extracted + * environment_details to the system prompt, but the new approach merges them into + * tool_result content like r1-format does with mergeToolResultText. + */ +export function extractEnvironmentDetailsForMiniMax(messages: Anthropic.Messages.MessageParam[]): { + messages: Anthropic.Messages.MessageParam[] + extractedSystemContent: string[] +} { + // For backwards compatibility, just return the merged messages with empty extracted content + return { + messages: mergeEnvironmentDetailsForMiniMax(messages), + extractedSystemContent: [], + } +} diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index 56d6441c48..de48d27a3f 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -11,6 +11,14 @@ export interface ConvertToOpenAiMessagesOptions { * This allows callers to declare provider-specific ID format requirements. */ normalizeToolCallId?: (id: string) => string + /** + * If true, merge text content after tool_results into the last tool message + * instead of creating a separate user message. This is critical for providers + * with reasoning/thinking models (like DeepSeek-reasoner, GLM-4.7, etc.) where + * a user message after tool results causes the model to drop all previous + * reasoning_content. Default is false for backward compatibility. + */ + mergeToolResultText?: boolean } export function convertToOpenAiMessages( @@ -19,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, @@ -95,18 +138,40 @@ export function convertToOpenAiMessages( // Process non-tool messages if (nonToolMessages.length > 0) { - openAiMessages.push({ - role: "user", - content: nonToolMessages.map((part) => { - if (part.type === "image") { - return { - type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + // Check if we should merge text into the last tool message + // This is critical for reasoning/thinking models where a user message + // after tool results causes the model to drop all previous reasoning_content + const hasOnlyTextContent = nonToolMessages.every((part) => part.type === "text") + const hasToolMessages = toolMessages.length > 0 + const shouldMergeIntoToolMessage = + options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent + + if (shouldMergeIntoToolMessage) { + // Merge text content into the last tool message + const lastToolMessage = openAiMessages[ + openAiMessages.length - 1 + ] as OpenAI.Chat.ChatCompletionToolMessageParam + if (lastToolMessage?.role === "tool") { + const additionalText = nonToolMessages + .map((part) => (part as Anthropic.TextBlockParam).text) + .join("\n") + lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` + } + } else { + // Standard behavior: add user message with text/image content + openAiMessages.push({ + role: "user", + content: nonToolMessages.map((part) => { + if (part.type === "image") { + return { + type: "image_url", + image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + } } - } - return { type: "text", text: part.text } - }), - }) + return { type: "text", text: part.text } + }), + }) + } } } else if (anthropicMessage.role === "assistant") { const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{ @@ -148,18 +213,30 @@ 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 - const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = { + + // 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[] + } = { role: "assistant", content, - // Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty - tool_calls: tool_calls.length > 0 ? tool_calls : undefined, } - // Preserve reasoning_details if present (will be processed by provider if needed) - if (messageWithDetails.reasoning_details && Array.isArray(messageWithDetails.reasoning_details)) { - ;(baseMessage as any).reasoning_details = messageWithDetails.reasoning_details + // 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 + // Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty + if (tool_calls.length > 0) { + baseMessage.tool_calls = tool_calls } openAiMessages.push(baseMessage) diff --git a/src/api/transform/zai-format.ts b/src/api/transform/zai-format.ts new file mode 100644 index 0000000000..79b2e88aeb --- /dev/null +++ b/src/api/transform/zai-format.ts @@ -0,0 +1,242 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText +type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage +type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam +type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam +type SystemMessage = OpenAI.Chat.ChatCompletionSystemMessageParam +type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam +type Message = OpenAI.Chat.ChatCompletionMessageParam +type AnthropicMessage = Anthropic.Messages.MessageParam + +/** + * Extended assistant message type to support Z.ai's interleaved thinking. + * Z.ai's API returns reasoning_content alongside content and tool_calls, + * and requires it to be passed back in subsequent requests for preserved thinking. + */ +export type ZAiAssistantMessage = AssistantMessage & { + reasoning_content?: string +} + +/** + * Converts Anthropic messages to OpenAI format optimized for Z.ai's GLM-4.7 thinking mode. + * + * Key differences from standard OpenAI format: + * - Preserves reasoning_content on assistant messages for interleaved thinking + * - Text content after tool_results (like environment_details) is merged into the last tool message + * to avoid creating user messages that would cause reasoning_content to be dropped + * + * @param messages Array of Anthropic messages + * @param options Optional configuration for message conversion + * @param options.mergeToolResultText If true, merge text content after tool_results into the last + * tool message instead of creating a separate user message. + * This is critical for Z.ai's interleaved thinking mode. + * @returns Array of OpenAI messages optimized for Z.ai's thinking mode + */ +export function convertToZAiFormat( + messages: AnthropicMessage[], + options?: { mergeToolResultText?: boolean }, +): Message[] { + const result: Message[] = [] + + for (const message of messages) { + // Check if the message has reasoning_content (for Z.ai interleaved thinking) + const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string } + const reasoningContent = messageWithReasoning.reasoning_content + + if (message.role === "user") { + // Handle user messages - may contain tool_result blocks + if (Array.isArray(message.content)) { + const textParts: string[] = [] + const imageParts: ContentPartImage[] = [] + const toolResults: { tool_use_id: string; content: string }[] = [] + + for (const part of message.content) { + if (part.type === "text") { + textParts.push(part.text) + } else if (part.type === "image") { + imageParts.push({ + type: "image_url", + image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + }) + } else if (part.type === "tool_result") { + // Convert tool_result to OpenAI tool message format + let content: string + if (typeof part.content === "string") { + content = part.content + } else if (Array.isArray(part.content)) { + content = + part.content + ?.map((c) => { + if (c.type === "text") return c.text + if (c.type === "image") return "(image)" + return "" + }) + .join("\n") ?? "" + } else { + content = "" + } + toolResults.push({ + tool_use_id: part.tool_use_id, + content, + }) + } + } + + // Add tool messages first (they must follow assistant tool_use) + for (const toolResult of toolResults) { + const toolMessage: ToolMessage = { + role: "tool", + tool_call_id: toolResult.tool_use_id, + content: toolResult.content, + } + result.push(toolMessage) + } + + // Handle text/image content after tool results + if (textParts.length > 0 || imageParts.length > 0) { + // For Z.ai interleaved thinking: when mergeToolResultText is enabled and we have + // tool results followed by text, merge the text into the last tool message to avoid + // creating a user message that would cause reasoning_content to be dropped. + // This is critical because Z.ai drops all reasoning_content when it sees a user message. + const shouldMergeIntoToolMessage = + options?.mergeToolResultText && toolResults.length > 0 && imageParts.length === 0 + + if (shouldMergeIntoToolMessage) { + // Merge text content into the last tool message + const lastToolMessage = result[result.length - 1] as ToolMessage + if (lastToolMessage?.role === "tool") { + const additionalText = textParts.join("\n") + lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` + } + } else { + // Standard behavior: add user message with text/image content + let content: UserMessage["content"] + if (imageParts.length > 0) { + const parts: (ContentPartText | ContentPartImage)[] = [] + if (textParts.length > 0) { + parts.push({ type: "text", text: textParts.join("\n") }) + } + parts.push(...imageParts) + content = parts + } else { + content = textParts.join("\n") + } + + // Check if we can merge with the last message + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "user") { + // Merge with existing user message + if (typeof lastMessage.content === "string" && typeof content === "string") { + lastMessage.content += `\n${content}` + } else { + const lastContent = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text" as const, text: lastMessage.content || "" }] + const newContent = Array.isArray(content) + ? content + : [{ type: "text" as const, text: content }] + lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] + } + } else { + result.push({ role: "user", content }) + } + } + } + } else { + // Simple string content + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "user") { + if (typeof lastMessage.content === "string") { + lastMessage.content += `\n${message.content}` + } else { + ;(lastMessage.content as (ContentPartText | ContentPartImage)[]).push({ + type: "text", + text: message.content, + }) + } + } else { + result.push({ role: "user", content: message.content }) + } + } + } else if (message.role === "assistant") { + // Handle assistant messages - may contain tool_use blocks and reasoning blocks + if (Array.isArray(message.content)) { + const textParts: string[] = [] + const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [] + let extractedReasoning: string | undefined + + for (const part of message.content) { + if (part.type === "text") { + textParts.push(part.text) + } else if (part.type === "tool_use") { + toolCalls.push({ + id: part.id, + type: "function", + function: { + name: part.name, + arguments: JSON.stringify(part.input), + }, + }) + } else if ((part as any).type === "reasoning" && (part as any).text) { + // Extract reasoning from content blocks (Task stores it this way) + extractedReasoning = (part as any).text + } + } + + // Use reasoning from content blocks if not provided at top level + const finalReasoning = reasoningContent || extractedReasoning + + const assistantMessage: ZAiAssistantMessage = { + role: "assistant", + content: textParts.length > 0 ? textParts.join("\n") : null, + ...(toolCalls.length > 0 && { tool_calls: toolCalls }), + // Preserve reasoning_content for Z.ai interleaved thinking + ...(finalReasoning && { reasoning_content: finalReasoning }), + } + + // Check if we can merge with the last message (only if no tool calls) + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "assistant" && !toolCalls.length && !(lastMessage as any).tool_calls) { + // Merge text content + if (typeof lastMessage.content === "string" && typeof assistantMessage.content === "string") { + lastMessage.content += `\n${assistantMessage.content}` + } else if (assistantMessage.content) { + const lastContent = lastMessage.content || "" + lastMessage.content = `${lastContent}\n${assistantMessage.content}` + } + // Preserve reasoning_content from the new message if present + if (finalReasoning) { + ;(lastMessage as ZAiAssistantMessage).reasoning_content = finalReasoning + } + } else { + result.push(assistantMessage) + } + } else { + // Simple string content + const lastMessage = result[result.length - 1] + if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) { + if (typeof lastMessage.content === "string") { + lastMessage.content += `\n${message.content}` + } else { + lastMessage.content = message.content + } + // Preserve reasoning_content from the new message if present + if (reasoningContent) { + ;(lastMessage as ZAiAssistantMessage).reasoning_content = reasoningContent + } + } else { + const assistantMessage: ZAiAssistantMessage = { + role: "assistant", + content: message.content, + ...(reasoningContent && { reasoning_content: reasoningContent }), + } + result.push(assistantMessage) + } + } + } + } + + return result +} diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 89bc1a9c34..f6eac36a9c 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -1,13 +1,16 @@ +import { parseJSON } from "partial-json" + import { type ToolName, toolNames, type FileEntry } from "@roo-code/types" +import { customToolRegistry } from "@roo-code/core" + import { type ToolUse, type McpToolUse, type ToolParamName, - toolParamNames, type NativeToolArgs, + toolParamNames, } from "../../shared/tools" import { resolveToolAlias } from "../prompts/tools/filter-tools-for-mode" -import { parseJSON } from "partial-json" import type { ApiStreamToolCallStartChunk, ApiStreamToolCallDeltaChunk, @@ -573,6 +576,7 @@ export class NativeToolCallParser { }): ToolUse | McpToolUse | null { // Check if this is a dynamic MCP tool (mcp--serverName--toolName) const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR + if (typeof toolCall.name === "string" && toolCall.name.startsWith(mcpPrefix)) { return this.parseDynamicMcpTool(toolCall) } @@ -580,8 +584,8 @@ export class NativeToolCallParser { // Resolve tool alias to canonical name const resolvedName = resolveToolAlias(toolCall.name as string) as TName - // Validate tool name (after alias resolution) - if (!toolNames.includes(resolvedName as ToolName)) { + // Validate tool name (after alias resolution). + if (!toolNames.includes(resolvedName as ToolName) && !customToolRegistry.has(resolvedName)) { console.error(`Invalid tool name: ${toolCall.name} (resolved: ${resolvedName})`) console.error(`Valid tool names:`, toolNames) return null @@ -589,7 +593,7 @@ export class NativeToolCallParser { try { // Parse the arguments JSON string - const args = JSON.parse(toolCall.arguments) + const args = toolCall.arguments === "" ? {} : JSON.parse(toolCall.arguments) // Build legacy params object for backward compatibility with XML protocol and UI. // Native execution path uses nativeArgs instead, which has proper typing. @@ -604,7 +608,7 @@ export class NativeToolCallParser { } // Validate parameter name - if (!toolParamNames.includes(key as ToolParamName)) { + if (!toolParamNames.includes(key as ToolParamName) && !customToolRegistry.has(resolvedName)) { console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`) console.warn(`Valid param names:`, toolParamNames) continue @@ -816,6 +820,10 @@ export class NativeToolCallParser { break default: + if (customToolRegistry.has(resolvedName)) { + nativeArgs = args as NativeArgsFor + } + 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 004e76af56..28cb038d3e 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -5,6 +5,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" import { ConsecutiveMistakeError } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import { customToolRegistry } from "@roo-code/core" import { t } from "../../i18n" @@ -18,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" @@ -115,12 +115,12 @@ export async function presentAssistantMessage(cline: Task) { : `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.` if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } break } @@ -130,12 +130,12 @@ export async function presentAssistantMessage(cline: Task) { const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.` if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: errorMessage, is_error: true, - } as Anthropic.ToolResultBlockParam) + }) } break } @@ -167,11 +167,11 @@ export async function presentAssistantMessage(cline: Task) { } if (toolCallId) { - cline.userMessageContent.push({ + cline.pushToolResultToUserContent({ type: "tool_result", tool_use_id: toolCallId, content: resultContent, - } as Anthropic.ToolResultBlockParam) + }) if (imageBlocks.length > 0) { cline.userMessageContent.push(...imageBlocks) @@ -362,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": @@ -452,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({ @@ -477,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({ @@ -536,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) { @@ -701,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. @@ -738,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) @@ -908,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">, { @@ -1070,9 +1052,8 @@ export async function presentAssistantMessage(cline: Task) { }) break default: { - // Handle unknown/invalid tool names + // Handle unknown/invalid tool names OR custom tools // This is critical for native protocol where every tool_use MUST have a tool_result - // Note: This case should rarely be reached since validateToolUse now checks for unknown tools // CRITICAL: Don't process partial blocks for unknown tools - just let them stream in. // If we try to show errors for partial blocks, we'd show the error on every streaming chunk, @@ -1081,6 +1062,47 @@ export async function presentAssistantMessage(cline: Task) { break } + const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined + + if (customTool) { + try { + let customToolArgs + + if (customTool.parameters) { + try { + customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {}) + } catch (parseParamsError) { + const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}` + console.error(message) + cline.consecutiveMistakeCount++ + await cline.say("error", message) + pushToolResult(formatResponse.toolError(message, toolProtocol)) + break + } + } + + const result = await customTool.execute(customToolArgs, { + mode: mode ?? defaultModeSlug, + task: cline, + }) + + console.log( + `${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`, + ) + + pushToolResult(result) + 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) + } + + break + } + + // Not a custom tool - handle as unknown tool error const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` cline.consecutiveMistakeCount++ cline.recordToolError(block.name as ToolName, errorMessage) @@ -1088,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__/condense.spec.ts b/src/core/condense/__tests__/condense.spec.ts index 2558ee5b33..bea7d50ac1 100644 --- a/src/core/condense/__tests__/condense.spec.ts +++ b/src/core/condense/__tests__/condense.spec.ts @@ -86,7 +86,19 @@ describe("Condense", () => { // Verify we have a summary message const summaryMessage = result.messages.find((msg) => msg.isSummary) expect(summaryMessage).toBeTruthy() - expect(summaryMessage?.content).toBe("Mock summary of the conversation") + // Summary content is now always an array with a synthetic reasoning block + text block + // for DeepSeek-reasoner compatibility + expect(Array.isArray(summaryMessage?.content)).toBe(true) + const contentArray = summaryMessage?.content as Anthropic.Messages.ContentBlockParam[] + expect(contentArray).toHaveLength(2) + expect(contentArray[0]).toEqual({ + type: "reasoning", + text: "Condensing conversation context. The summary below captures the key information from the prior conversation.", + }) + expect(contentArray[1]).toEqual({ + type: "text", + text: "Mock summary of the conversation", + }) // With non-destructive condensing, all messages are retained (tagged but not deleted) // Use getEffectiveApiHistory to verify the effective view matches the old behavior diff --git a/src/core/condense/__tests__/index.spec.ts b/src/core/condense/__tests__/index.spec.ts index fe17b09ee3..ef5af01243 100644 --- a/src/core/condense/__tests__/index.spec.ts +++ b/src/core/condense/__tests__/index.spec.ts @@ -246,6 +246,342 @@ describe("getKeepMessagesWithToolBlocks", () => { expect(result.keepMessages).toEqual(messages) expect(result.toolUseBlocksToPreserve).toHaveLength(0) }) + + it("should preserve reasoning blocks alongside tool_use blocks for DeepSeek/Z.ai interleaved thinking", () => { + const reasoningBlock = { + type: "reasoning" as const, + text: "Let me think about this step by step...", + } + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_deepseek_123", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_deepseek_123", + content: "file contents", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Let me help", ts: 2 }, + { role: "user", content: "Please read the file", ts: 3 }, + { + role: "assistant", + // DeepSeek stores reasoning as content blocks alongside tool_use + content: [reasoningBlock as any, { type: "text" as const, text: "Reading file..." }, toolUseBlock], + ts: 4, + }, + { + role: "user", + content: [toolResultBlock, { type: "text" as const, text: "Continue" }], + ts: 5, + }, + { role: "assistant", content: "Got it, the file says...", ts: 6 }, + { role: "user", content: "Thanks", ts: 7 }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + // keepMessages should be the last 3 messages + expect(result.keepMessages).toHaveLength(3) + expect(result.keepMessages[0].ts).toBe(5) + + // Should preserve the tool_use block + expect(result.toolUseBlocksToPreserve).toHaveLength(1) + expect(result.toolUseBlocksToPreserve[0]).toEqual(toolUseBlock) + + // Should preserve the reasoning block for DeepSeek/Z.ai interleaved thinking + expect(result.reasoningBlocksToPreserve).toHaveLength(1) + expect((result.reasoningBlocksToPreserve[0] as any).type).toBe("reasoning") + expect((result.reasoningBlocksToPreserve[0] as any).text).toBe("Let me think about this step by step...") + }) + + it("should return empty reasoningBlocksToPreserve when no reasoning blocks present", () => { + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_123", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_123", + content: "file contents", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { + role: "assistant", + // No reasoning block, just text and tool_use + content: [{ type: "text" as const, text: "Reading file..." }, toolUseBlock], + ts: 2, + }, + { + role: "user", + content: [toolResultBlock], + ts: 3, + }, + { role: "assistant", content: "Done", ts: 4 }, + { role: "user", content: "Thanks", ts: 5 }, + ] + + const result = getKeepMessagesWithToolBlocks(messages, 3) + + 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", () => { @@ -422,7 +758,14 @@ describe("summarizeConversation", () => { const summaryMessage = result.messages.find((m) => m.isSummary) expect(summaryMessage).toBeDefined() expect(summaryMessage!.role).toBe("assistant") - expect(summaryMessage!.content).toBe("This is a summary") + // Summary content is now always an array with [synthetic reasoning, text] + // for DeepSeek-reasoner compatibility (requires reasoning_content on all assistant messages) + expect(Array.isArray(summaryMessage!.content)).toBe(true) + const content = summaryMessage!.content as any[] + expect(content).toHaveLength(2) + expect(content[0].type).toBe("reasoning") + expect(content[1].type).toBe("text") + expect(content[1].text).toBe("This is a summary") expect(summaryMessage!.isSummary).toBe(true) // Verify that the effective API history matches expected: first + summary + last N messages @@ -827,14 +1170,16 @@ describe("summarizeConversation", () => { expect(summaryMessage!.isSummary).toBe(true) expect(Array.isArray(summaryMessage!.content)).toBe(true) - // Content should be [text block, tool_use block] + // Content should be [synthetic reasoning, text block, tool_use block] + // The synthetic reasoning is always added for DeepSeek-reasoner compatibility const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(content).toHaveLength(2) - expect(content[0].type).toBe("text") - expect((content[0] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation") - expect(content[1].type).toBe("tool_use") - expect((content[1] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_123") - expect((content[1] as Anthropic.Messages.ToolUseBlockParam).name).toBe("read_file") + expect(content).toHaveLength(3) + expect((content[0] as any).type).toBe("reasoning") // Synthetic reasoning for DeepSeek + expect(content[1].type).toBe("text") + expect((content[1] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation") + expect(content[2].type).toBe("tool_use") + expect((content[2] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_123") + expect((content[2] as Anthropic.Messages.ToolUseBlockParam).name).toBe("read_file") // With non-destructive condensing, all messages are retained plus the summary expect(result.messages.length).toBe(messages.length + 1) // all original + summary @@ -981,7 +1326,10 @@ describe("summarizeConversation", () => { expect(summaryMessage).toBeDefined() expect(Array.isArray(summaryMessage!.content)).toBe(true) const summaryContent = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] - expect(summaryContent[0]).toEqual({ type: "text", text: "This is a summary" }) + // First block is synthetic reasoning for DeepSeek-reasoner compatibility + expect((summaryContent[0] as any).type).toBe("reasoning") + // Second block is the text summary + expect(summaryContent[1]).toEqual({ type: "text", text: "This is a summary" }) const preservedToolUses = summaryContent.filter( (block): block is Anthropic.Messages.ToolUseBlockParam => block.type === "tool_use", @@ -989,6 +1337,153 @@ describe("summarizeConversation", () => { expect(preservedToolUses).toHaveLength(2) expect(preservedToolUses.map((block) => block.id)).toEqual(["toolu_parallel_1", "toolu_parallel_2"]) }) + + it("should preserve reasoning blocks in summary message for DeepSeek/Z.ai interleaved thinking", async () => { + const reasoningBlock = { + type: "reasoning" as const, + text: "Let me think about this step by step...", + } + const toolUseBlock = { + type: "tool_use" as const, + id: "toolu_deepseek_reason", + name: "read_file", + input: { path: "test.txt" }, + } + const toolResultBlock = { + type: "tool_result" as const, + tool_use_id: "toolu_deepseek_reason", + content: "file contents", + } + + const messages: ApiMessage[] = [ + { role: "user", content: "Hello", ts: 1 }, + { role: "assistant", content: "Let me help", ts: 2 }, + { role: "user", content: "Please read the file", ts: 3 }, + { + role: "assistant", + // DeepSeek stores reasoning as content blocks alongside tool_use + content: [reasoningBlock as any, { type: "text" as const, text: "Reading file..." }, toolUseBlock], + ts: 4, + }, + { + role: "user", + content: [toolResultBlock, { type: "text" as const, text: "Continue" }], + ts: 5, + }, + { role: "assistant", content: "Got it, the file says...", ts: 6 }, + { role: "user", content: "Thanks", ts: 7 }, + ] + + // Create a stream with usage information + const streamWithUsage = (async function* () { + yield { type: "text" as const, text: "Summary of conversation" } + yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 } + })() + + mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any + mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any + + const result = await summarizeConversation( + messages, + mockApiHandler, + defaultSystemPrompt, + taskId, + DEFAULT_PREV_CONTEXT_TOKENS, + false, // isAutomaticTrigger + undefined, // customCondensingPrompt + undefined, // condensingApiHandler + true, // useNativeTools - required for tool_use block preservation + ) + + // Find the summary message + const summaryMessage = result.messages.find((m) => m.isSummary) + expect(summaryMessage).toBeDefined() + expect(summaryMessage!.role).toBe("assistant") + expect(summaryMessage!.isSummary).toBe(true) + expect(Array.isArray(summaryMessage!.content)).toBe(true) + + // Content should be [synthetic reasoning, preserved reasoning, text block, tool_use block] + // - Synthetic reasoning is always added for DeepSeek-reasoner compatibility + // - Preserved reasoning from the condensed assistant message + // This order ensures reasoning_content is always present for DeepSeek/Z.ai + const content = summaryMessage!.content as Anthropic.Messages.ContentBlockParam[] + expect(content).toHaveLength(4) + + // First block should be synthetic reasoning + expect((content[0] as any).type).toBe("reasoning") + expect((content[0] as any).text).toContain("Condensing conversation context") + + // Second block should be preserved reasoning from the condensed message + expect((content[1] as any).type).toBe("reasoning") + expect((content[1] as any).text).toBe("Let me think about this step by step...") + + // Third block should be text (the summary) + expect(content[2].type).toBe("text") + expect((content[2] as Anthropic.Messages.TextBlockParam).text).toBe("Summary of conversation") + + // Fourth block should be tool_use + expect(content[3].type).toBe("tool_use") + expect((content[3] as Anthropic.Messages.ToolUseBlockParam).id).toBe("toolu_deepseek_reason") + + expect(result.error).toBeUndefined() + }) + + it("should include synthetic reasoning block in summary for DeepSeek-reasoner compatibility even without tool_use blocks", async () => { + // This test verifies the fix for the DeepSeek-reasoner 400 error: + // "Missing `reasoning_content` field in the assistant message at message index 1" + // DeepSeek-reasoner requires reasoning_content on ALL assistant messages, not just those with tool_calls. + // After condensation, the summary becomes an assistant message that needs reasoning_content. + const messages: ApiMessage[] = [ + { role: "user", content: "Tell me a joke", ts: 1 }, + { role: "assistant", content: "Why did the programmer quit?", ts: 2 }, + { role: "user", content: "I don't know, why?", ts: 3 }, + { role: "assistant", content: "He didn't get arrays!", ts: 4 }, + { role: "user", content: "Another one please", ts: 5 }, + { role: "assistant", content: "Why do programmers prefer dark mode?", ts: 6 }, + { role: "user", content: "Why?", ts: 7 }, + ] + + // Create a stream with usage information (no tool calls in this conversation) + const streamWithUsage = (async function* () { + yield { type: "text" as const, text: "Summary: User requested jokes." } + yield { type: "usage" as const, totalCost: 0.05, outputTokens: 100 } + })() + + mockApiHandler.createMessage = vi.fn().mockReturnValue(streamWithUsage) as any + mockApiHandler.countTokens = vi.fn().mockImplementation(() => Promise.resolve(50)) as any + + const result = await summarizeConversation( + messages, + mockApiHandler, + defaultSystemPrompt, + taskId, + DEFAULT_PREV_CONTEXT_TOKENS, + false, // isAutomaticTrigger + undefined, // customCondensingPrompt + undefined, // condensingApiHandler + false, // useNativeTools - not using tools in this test + ) + + // Find the summary message + const summaryMessage = result.messages.find((m) => m.isSummary) + expect(summaryMessage).toBeDefined() + expect(summaryMessage!.role).toBe("assistant") + expect(summaryMessage!.isSummary).toBe(true) + + // CRITICAL: Content must be an array with a synthetic reasoning block + // This is required for DeepSeek-reasoner which needs reasoning_content on all assistant messages + expect(Array.isArray(summaryMessage!.content)).toBe(true) + const content = summaryMessage!.content as any[] + + // Should have [synthetic reasoning, text] + expect(content).toHaveLength(2) + expect(content[0].type).toBe("reasoning") + expect(content[0].text).toContain("Condensing conversation context") + expect(content[1].type).toBe("text") + expect(content[1].text).toBe("Summary: User requested jokes.") + + expect(result.error).toBeUndefined() + }) }) describe("summarizeConversation with custom settings", () => { diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index b8af4d1de2..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,42 +31,123 @@ 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, + * which convertToR1Format and convertToZAiFormat already know how to extract. + */ +function getReasoningBlocks(message: ApiMessage): Anthropic.Messages.ContentBlockParam[] { + if (message.role !== "assistant" || typeof message.content === "string") { + return [] + } + // Filter for reasoning blocks and cast to ContentBlockParam (the type field is compatible) + return message.content.filter((block) => (block as any).type === "reasoning") as any[] +} + +/** + * Result of getKeepMessagesWithToolBlocks + */ +export type KeepMessagesResult = { + keepMessages: ApiMessage[] + toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] + // Reasoning blocks from the preceding assistant message, needed for DeepSeek/Z.ai + // when tool_use blocks are preserved. Task stores reasoning as {type: "reasoning", text: "..."} + // blocks, and convertToR1Format/convertToZAiFormat already extract these. + reasoningBlocksToPreserve: Anthropic.Messages.ContentBlockParam[] +} + /** * 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 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 + * * @param messages - The full conversation messages * @param keepCount - The number of messages to keep from the end - * @returns Object containing keepMessages and any tool_use blocks to preserve + * @returns Object containing keepMessages, tool_use blocks, and reasoning blocks to preserve */ -export function getKeepMessagesWithToolBlocks( - messages: ApiMessage[], - keepCount: number, -): { keepMessages: ApiMessage[]; toolUseBlocksToPreserve: Anthropic.Messages.ToolUseBlock[] } { +export function getKeepMessagesWithToolBlocks(messages: ApiMessage[], keepCount: number): KeepMessagesResult { if (messages.length <= keepCount) { - return { keepMessages: messages, toolUseBlocksToPreserve: [] } + return { keepMessages: messages, toolUseBlocksToPreserve: [], reasoningBlocksToPreserve: [] } } 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) { - // Return the tool_use blocks to be merged into the summary message - return { keepMessages, toolUseBlocksToPreserve: toolUseBlocks } + 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: [] } + return { + keepMessages, + toolUseBlocksToPreserve, + reasoningBlocksToPreserve, + } } export const N_MESSAGES_TO_KEEP = 3 @@ -168,11 +250,15 @@ export async function summarizeConversation( // Always preserve the first message (which may contain slash command content) const firstMessage = messages[0] - // Get keepMessages and any tool_use blocks that need to be preserved for tool_result pairing - // Only preserve tool_use blocks when using native tools protocol (XML protocol doesn't need them) - const { keepMessages, toolUseBlocksToPreserve } = useNativeTools + // Get keepMessages and any tool_use/reasoning blocks that need to be preserved for tool_result pairing + // Only preserve these blocks when using native tools protocol (XML protocol doesn't need them) + const { keepMessages, toolUseBlocksToPreserve, reasoningBlocksToPreserve } = useNativeTools ? getKeepMessagesWithToolBlocks(messages, N_MESSAGES_TO_KEEP) - : { keepMessages: messages.slice(-N_MESSAGES_TO_KEEP), toolUseBlocksToPreserve: [] } + : { + keepMessages: messages.slice(-N_MESSAGES_TO_KEEP), + toolUseBlocksToPreserve: [], + reasoningBlocksToPreserve: [], + } const keepStartIndex = Math.max(messages.length - N_MESSAGES_TO_KEEP, 0) const includeFirstKeptMessageInSummary = toolUseBlocksToPreserve.length > 0 @@ -257,15 +343,39 @@ export async function summarizeConversation( } // Build the summary message content - // If there are tool_use blocks to preserve (for tool_result pairing), append them to the summary - let summaryContent: string | Anthropic.Messages.ContentBlockParam[] + // CRITICAL: Always include a reasoning block in the summary for DeepSeek-reasoner compatibility. + // DeepSeek-reasoner requires `reasoning_content` on ALL assistant messages, not just those with tool_calls. + // Without this, we get: "400 Missing `reasoning_content` field in the assistant message" + // See: https://api-docs.deepseek.com/guides/thinking_mode + // + // The summary content structure is: + // 1. Synthetic reasoning block (always present) - for DeepSeek-reasoner compatibility + // 2. Any preserved reasoning blocks from the condensed assistant message (if tool_use blocks are preserved) + // 3. Text block with the summary + // 4. Tool_use blocks (if any need to be preserved for tool_result pairing) + + // Create a synthetic reasoning block that explains the summary + // This is minimal but satisfies DeepSeek's requirement for reasoning_content on all assistant messages + const syntheticReasoningBlock = { + type: "reasoning" as const, + text: "Condensing conversation context. The summary below captures the key information from the prior conversation.", + } + + const textBlock: Anthropic.Messages.TextBlockParam = { type: "text", text: summary } + + let summaryContent: Anthropic.Messages.ContentBlockParam[] if (toolUseBlocksToPreserve.length > 0) { - // Create content array with text block followed by tool_use blocks - // Use TextBlockParam which doesn't require citations field - const textBlock: Anthropic.Messages.TextBlockParam = { type: "text", text: summary } - summaryContent = [textBlock, ...toolUseBlocksToPreserve] + // Include: synthetic reasoning, preserved reasoning (if any), summary text, and tool_use blocks + summaryContent = [ + syntheticReasoningBlock as unknown as Anthropic.Messages.ContentBlockParam, + ...reasoningBlocksToPreserve, + textBlock, + ...toolUseBlocksToPreserve, + ] } else { - summaryContent = summary + // Include: synthetic reasoning and summary text + // This ensures the summary always has reasoning_content for DeepSeek-reasoner + summaryContent = [syntheticReasoningBlock as unknown as Anthropic.Messages.ContentBlockParam, textBlock] } // Generate a unique condenseId for this summary 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/index.ts b/src/core/mentions/index.ts index f038b5b783..9ee7cece5d 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( @@ -410,3 +420,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/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__/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/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/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 75b28bbb21..040d703929 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -1,9 +1,15 @@ import * as vscode from "vscode" import * as os from "os" -import type { ModeConfig, PromptComponent, CustomModePrompts, TodoItem } from "@roo-code/types" - -import type { SystemPromptSettings } from "./types" +import { + type ModeConfig, + type PromptComponent, + type CustomModePrompts, + type TodoItem, + getEffectiveProtocol, + isNativeProtocol, +} from "@roo-code/types" +import { customToolRegistry, formatXml } from "@roo-code/core" import { Mode, modes, defaultModeSlug, getModeBySlug, getGroupName, getModeSelection } from "../../shared/modes" import { DiffStrategy } from "../../shared/tools" @@ -12,11 +18,12 @@ 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" +import type { SystemPromptSettings } from "./types" import { getToolDescriptionsForMode } from "./tools" -import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types" import { getRulesSection, getSystemInfoSection, @@ -28,6 +35,7 @@ import { getModesSection, addCustomInstructions, markdownFormattingSection, + getSkillsSection, } from "./sections" // Helper function to get prompt component, filtering out empty objects @@ -63,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") @@ -85,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( @@ -95,10 +104,11 @@ async function generatePrompt( !isNativeProtocol(effectiveProtocol), ) : Promise.resolve(""), + getSkillsSection(skillsManager, mode as string), ]) // Build tools catalog section only for XML protocol - const toolsCatalog = isNativeProtocol(effectiveProtocol) + const builtInToolsCatalog = isNativeProtocol(effectiveProtocol) ? "" : `\n\n${getToolDescriptionsForMode( mode, @@ -116,20 +126,32 @@ async function generatePrompt( modelId, )}` + let customToolsSection = "" + + if (experiments?.customTools && !isNativeProtocol(effectiveProtocol)) { + const customTools = customToolRegistry.getAllSerialized() + + if (customTools.length > 0) { + customToolsSection = `\n\n${formatXml(customTools)}` + } + } + + const toolsCatalog = builtInToolsCatalog + customToolsSection + const basePrompt = `${roleDefinition} ${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)} @@ -165,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") @@ -237,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/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/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 9263115c60..097679e4a7 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -20,6 +20,9 @@ export type ApiMessage = Anthropic.MessageParam & { text?: string // For OpenRouter reasoning_details array format (used by Gemini 3, etc.) reasoning_details?: any[] + // For DeepSeek/Z.ai interleaved thinking: reasoning_content that must be preserved during tool call sequences + // See: https://api-docs.deepseek.com/guides/thinking_mode#tool-calls + reasoning_content?: string // For non-destructive condense: unique identifier for summary messages condenseId?: string // For non-destructive condense: points to the condenseId of the summary that replaces this message diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 92ea6aa957..3340ae5d6c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -311,6 +311,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 +337,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 @@ -1089,7 +1112,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 +1136,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 +1149,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 +1159,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 +1188,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 +1229,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 +1251,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 +1363,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 +1437,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 +2033,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 +2360,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 +2385,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 +2397,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 +2524,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 +2577,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 +3229,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 +3364,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 +3517,7 @@ export class Task extends EventEmitter implements TaskLike { maxConcurrentFileReads, maxReadFileLine, apiConfiguration, + enableSubfolderRules, } = state ?? {} return await (async () => { @@ -3487,6 +3570,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 +3579,7 @@ export class Task extends EventEmitter implements TaskLike { }, undefined, // todoList this.api.getModel().id, + provider.getSkillsManager(), ) })() } @@ -3596,7 +3681,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 +3755,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 +3936,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, 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__/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 575b31580e..52a9f2eb82 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -1,6 +1,13 @@ +import path from "path" + import type OpenAI from "openai" + import type { ProviderSettings, ModeConfig, ModelInfo } from "@roo-code/types" +import { customToolRegistry, formatNative } from "@roo-code/core" + import type { ClineProvider } from "../webview/ClineProvider" +import { getRooDirectoriesForCwd } from "../../services/roo-config/index.js" + import { getNativeTools, getMcpServerTools } from "../prompts/tools/native-tools" import { filterNativeToolsForMode, filterMcpToolsForMode } from "../prompts/tools/filter-tools-for-mode" @@ -12,6 +19,7 @@ interface BuildToolsOptions { experiments: Record | undefined apiConfiguration: ProviderSettings | undefined maxReadFileLine: number + maxConcurrentFileReads: number browserToolEnabled: boolean modelInfo?: ModelInfo diffEnabled: boolean @@ -33,6 +41,7 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise experiments, apiConfiguration, maxReadFileLine, + maxConcurrentFileReads, browserToolEnabled, modelInfo, diffEnabled, @@ -40,11 +49,11 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise const mcpHub = provider.getMcpHub() - // Get CodeIndexManager for feature checking + // Get CodeIndexManager for feature checking. const { CodeIndexManager } = await import("../../services/code-index/manager") const codeIndexManager = CodeIndexManager.getInstance(provider.context, cwd) - // Build settings object for tool filtering + // Build settings object for tool filtering. const filterSettings = { todoListEnabled: apiConfiguration?.todoListEnabled ?? true, browserToolEnabled: browserToolEnabled ?? true, @@ -52,13 +61,20 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise diffEnabled, } - // Determine if partial reads are enabled based on maxReadFileLine setting + // 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 - // Filter native tools based on mode restrictions + // 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( nativeTools, mode, @@ -69,9 +85,22 @@ export async function buildNativeToolsArray(options: BuildToolsOptions): Promise mcpHub, ) - // Filter MCP tools based on mode restrictions + // Filter MCP tools based on mode restrictions. const mcpTools = getMcpServerTools(mcpHub) const filteredMcpTools = filterMcpToolsForMode(mcpTools, mode, customModes, experiments) - return [...filteredNativeTools, ...filteredMcpTools] + // Add custom tools if they are available and the experiment is enabled. + let nativeCustomTools: OpenAI.Chat.ChatCompletionFunctionTool[] = [] + + if (experiments?.customTools) { + const toolDirs = getRooDirectoriesForCwd(cwd).map((dir) => path.join(dir, "tools")) + await customToolRegistry.loadFromDirectoriesIfStale(toolDirs) + const customTools = customToolRegistry.getAllSerialized() + + if (customTools.length > 0) { + nativeCustomTools = customTools.map(formatNative) + } + } + + return [...filteredNativeTools, ...filteredMcpTools, ...nativeCustomTools] } 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/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 49d159f455..7d03a6a22c 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -111,6 +111,8 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { let fileContent: string try { fileContent = await fs.readFile(absolutePath, "utf8") + // Normalize line endings to LF for consistent matching + fileContent = fileContent.replace(/\r\n/g, "\n") } catch (error) { task.consecutiveMistakeCount++ task.recordToolError("search_and_replace") @@ -125,7 +127,9 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { const errors: string[] = [] for (let i = 0; i < operations.length; i++) { - const { search, replace } = operations[i] + // Normalize line endings in search/replace strings to match file content + const search = operations[i].search.replace(/\r\n/g, "\n") + const replace = operations[i].replace.replace(/\r\n/g, "\n") const searchPattern = new RegExp(escapeRegExp(search), "g") const matchCount = newContent.match(searchPattern)?.length ?? 0 diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index b0150d04a1..dadb97fde5 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -105,6 +105,8 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { let fileContent: string try { fileContent = await fs.readFile(absolutePath, "utf8") + // Normalize line endings to LF for consistent matching + fileContent = fileContent.replace(/\r\n/g, "\n") } catch (error) { task.consecutiveMistakeCount++ task.recordToolError("search_replace") @@ -114,8 +116,12 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { return } + // Normalize line endings in search/replace strings to match file content + const normalizedOldString = old_string.replace(/\r\n/g, "\n") + const normalizedNewString = new_string.replace(/\r\n/g, "\n") + // Check for exact match (literal string, not regex) - const matchCount = fileContent.split(old_string).length - 1 + const matchCount = fileContent.split(normalizedOldString).length - 1 if (matchCount === 0) { task.consecutiveMistakeCount++ @@ -142,7 +148,7 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { } // Apply the single replacement - const newContent = fileContent.replace(old_string, new_string) + const newContent = fileContent.replace(normalizedOldString, normalizedNewString) // Check if any changes were made if (newContent === fileContent) { 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__/applyDiffTool.experiment.spec.ts b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts index 4e0044c5ee..65d7cb6774 100644 --- a/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.experiment.spec.ts @@ -84,7 +84,7 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag = vi.fn((tag, value) => value) }) - it("should use legacy tool when MULTI_FILE_APPLY_DIFF experiment is disabled", async () => { + it("should always use class-based tool with native protocol (XML deprecated)", async () => { mockProvider.getState.mockResolvedValue({ experiments: { [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: false, @@ -103,16 +103,17 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) + // Always uses native protocol now (XML deprecated) expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, removeClosingTag: mockRemoveClosingTag, - toolProtocol: "xml", + toolProtocol: "native", }) }) - it("should use legacy tool when experiments are not defined", async () => { + it("should use class-based tool when experiments are not defined", async () => { mockProvider.getState.mockResolvedValue({}) // Mock the class-based tool to resolve successfully @@ -127,24 +128,26 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) + // Always uses native protocol now (XML deprecated) expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { askApproval: mockAskApproval, handleError: mockHandleError, pushToolResult: mockPushToolResult, removeClosingTag: mockRemoveClosingTag, - toolProtocol: "xml", + toolProtocol: "native", }) }) - it("should use multi-file tool when MULTI_FILE_APPLY_DIFF experiment is enabled and using XML protocol", async () => { + it("should use class-based tool when MULTI_FILE_APPLY_DIFF experiment is enabled (native protocol always used)", async () => { mockProvider.getState.mockResolvedValue({ experiments: { [EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF]: true, }, }) - // Mock the new tool behavior - it should continue with the multi-file implementation - // Since we're not mocking the entire function, we'll just verify it doesn't call the class-based tool + // Mock the class-based tool to resolve successfully + ;(applyDiffToolClass.handle as any).mockResolvedValue(undefined) + await multiApplyDiffTool( mockCline, mockBlock, @@ -154,7 +157,14 @@ describe("applyDiffTool experiment routing", () => { mockRemoveClosingTag, ) - expect(applyDiffToolClass.handle).not.toHaveBeenCalled() + // Native protocol is always used now, so class-based tool is always called + expect(applyDiffToolClass.handle).toHaveBeenCalledWith(mockCline, mockBlock, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "native", + }) }) it("should use class-based tool when model defaults to native protocol", async () => { diff --git a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts index 5a9a1af1da..0910550dd8 100644 --- a/src/core/tools/__tests__/multiApplyDiffTool.spec.ts +++ b/src/core/tools/__tests__/multiApplyDiffTool.spec.ts @@ -1,4 +1,5 @@ import { applyDiffTool } from "../MultiApplyDiffTool" +import { applyDiffTool as applyDiffToolClass } from "../ApplyDiffTool" import { EXPERIMENT_IDS } from "../../../shared/experiments" import * as fs from "fs/promises" import * as fileUtils from "../../../utils/fs" @@ -9,8 +10,12 @@ vi.mock("fs/promises") vi.mock("../../../utils/fs") vi.mock("../../../utils/path") vi.mock("../../../utils/xml") -vi.mock("../applyDiffTool", () => ({ - applyDiffToolLegacy: vi.fn(), + +// Mock the ApplyDiffTool class-based tool that MultiApplyDiffTool delegates to for native protocol +vi.mock("../ApplyDiffTool", () => ({ + applyDiffTool: { + handle: vi.fn().mockResolvedValue(undefined), + }, })) // Mock TelemetryService @@ -116,8 +121,8 @@ describe("multiApplyDiffTool", () => { ;(pathUtils.getReadablePath as any).mockImplementation((cwd: string, path: string) => path) }) - describe("Early content validation", () => { - it("should filter out non-string content at parse time", async () => { + describe("Native protocol delegation", () => { + it("should delegate to applyDiffToolClass.handle for XML args format", async () => { mockBlock = { params: { args: ` @@ -130,22 +135,6 @@ describe("multiApplyDiffTool", () => { partial: false, } - // Mock parseXml to return mixed content types - const parseXml = await import("../../../utils/xml") - ;(parseXml.parseXml as any).mockReturnValue({ - file: { - path: "test.ts", - diff: [ - { content: "<<<<<<< SEARCH\ntest\n=======\nreplaced\n>>>>>>> REPLACE" }, - { content: null }, - { content: undefined }, - { content: 42 }, - { content: "" }, // Empty string should also be filtered - { content: "<<<<<<< SEARCH\nmore\n=======\nchanges\n>>>>>>> REPLACE" }, - ], - }, - }) - await applyDiffTool( mockCline, mockBlock, @@ -155,23 +144,26 @@ describe("multiApplyDiffTool", () => { mockRemoveClosingTag, ) - // Should complete without error and only process valid string content - expect(mockPushToolResult).toHaveBeenCalled() - expect(mockHandleError).not.toHaveBeenCalled() - - // Verify that only valid diffs were processed - const resultCall = mockPushToolResult.mock.calls[0][0] - // Should not include the single block notice since we have 2 valid blocks - expect(resultCall).not.toContain("Making multiple related changes") + // Should delegate to the class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalled() + expect(applyDiffToolClass.handle).toHaveBeenCalledWith( + mockCline, + mockBlock, + expect.objectContaining({ + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "native", + }), + ) }) - }) - describe("SEARCH block counting with non-string content", () => { - it("should handle diffItem.content being undefined", async () => { + it("should delegate to applyDiffToolClass.handle for legacy path/diff params", async () => { mockBlock = { params: { path: "test.ts", - diff: undefined, // This will result in undefined content + diff: "<<<<<<< SEARCH\nold\n=======\nnew\n>>>>>>> REPLACE", }, partial: false, } @@ -185,12 +177,44 @@ describe("multiApplyDiffTool", () => { mockRemoveClosingTag, ) - // Should complete without throwing an error - expect(mockPushToolResult).toHaveBeenCalled() - expect(mockHandleError).not.toHaveBeenCalled() + // Should delegate to the class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalled() + expect(applyDiffToolClass.handle).toHaveBeenCalledWith( + mockCline, + mockBlock, + expect.objectContaining({ + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "native", + }), + ) }) - it("should handle diffItem.content being null", async () => { + it("should handle undefined diff content by delegating to class-based tool", async () => { + mockBlock = { + params: { + path: "test.ts", + diff: undefined, + }, + partial: false, + } + + await applyDiffTool( + mockCline, + mockBlock, + mockAskApproval, + mockHandleError, + mockPushToolResult, + mockRemoveClosingTag, + ) + + // Should delegate to the class-based tool (which will handle the error) + expect(applyDiffToolClass.handle).toHaveBeenCalled() + }) + + it("should handle null diff content by delegating to class-based tool", async () => { mockBlock = { params: { args: ` @@ -203,17 +227,6 @@ describe("multiApplyDiffTool", () => { partial: false, } - // Mock parseXml to return null content - const parseXml = await import("../../../utils/xml") - ;(parseXml.parseXml as any).mockReturnValue({ - file: { - path: "test.ts", - diff: { - content: null, - }, - }, - }) - await applyDiffTool( mockCline, mockBlock, @@ -223,50 +236,11 @@ describe("multiApplyDiffTool", () => { mockRemoveClosingTag, ) - // Should complete without throwing an error - expect(mockPushToolResult).toHaveBeenCalled() - expect(mockHandleError).not.toHaveBeenCalled() + // Should delegate to the class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalled() }) - it("should handle diffItem.content being a number", async () => { - mockBlock = { - params: { - args: ` - test.ts - - 123 - - `, - }, - partial: false, - } - - // Mock parseXml to return number content - const parseXml = await import("../../../utils/xml") - ;(parseXml.parseXml as any).mockReturnValue({ - file: { - path: "test.ts", - diff: { - content: 123, // Number instead of string - }, - }, - }) - - await applyDiffTool( - mockCline, - mockBlock, - mockAskApproval, - mockHandleError, - mockPushToolResult, - mockRemoveClosingTag, - ) - - // Should complete without throwing an error - expect(mockPushToolResult).toHaveBeenCalled() - expect(mockHandleError).not.toHaveBeenCalled() - }) - - it("should correctly count SEARCH blocks when content is a valid string", async () => { + it("should delegate multiple SEARCH blocks to class-based tool", async () => { const diffContent = `<<<<<<< SEARCH old content ======= @@ -296,14 +270,11 @@ another new content mockRemoveClosingTag, ) - // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalled() - const resultCall = mockPushToolResult.mock.calls[0][0] - // Should not include the single block notice since we have 2 blocks - expect(resultCall).not.toContain("Making multiple related changes") + // Should delegate to the class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalled() }) - it("should show single block notice when only one SEARCH block exists", async () => { + it("should delegate single SEARCH block to class-based tool", async () => { const diffContent = `<<<<<<< SEARCH old content ======= @@ -327,16 +298,13 @@ new content mockRemoveClosingTag, ) - // Should complete successfully - expect(mockPushToolResult).toHaveBeenCalled() - const resultCall = mockPushToolResult.mock.calls[0][0] - // Should include the single block notice - expect(resultCall).toContain("Making multiple related changes") + // Should delegate to the class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalled() }) }) describe("Edge cases for diff content", () => { - it("should handle empty diff array", async () => { + it("should handle empty diff by delegating to class-based tool", async () => { mockBlock = { params: { args: ` @@ -347,14 +315,6 @@ new content partial: false, } - const parseXml = await import("../../../utils/xml") - ;(parseXml.parseXml as any).mockReturnValue({ - file: { - path: "test.ts", - diff: [], - }, - }) - await applyDiffTool( mockCline, mockBlock, @@ -364,12 +324,12 @@ new content mockRemoveClosingTag, ) - // Should complete without error - expect(mockPushToolResult).toHaveBeenCalled() + // Should delegate to the class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalled() expect(mockHandleError).not.toHaveBeenCalled() }) - it("should handle mixed content types in diff array", async () => { + it("should handle mixed content types by delegating to class-based tool", async () => { mockBlock = { params: { args: ` @@ -382,20 +342,6 @@ new content partial: false, } - const parseXml = await import("../../../utils/xml") - ;(parseXml.parseXml as any).mockReturnValue({ - file: { - path: "test.ts", - diff: [ - { content: "<<<<<<< SEARCH\ntest\n=======\nreplaced\n>>>>>>> REPLACE" }, - { content: null }, - { content: undefined }, - { content: 42 }, - { content: "<<<<<<< SEARCH\nmore\n=======\nchanges\n>>>>>>> REPLACE" }, - ], - }, - }) - await applyDiffTool( mockCline, mockBlock, @@ -405,8 +351,8 @@ new content mockRemoveClosingTag, ) - // Should complete without error and count only valid string SEARCH blocks - expect(mockPushToolResult).toHaveBeenCalled() + // Should delegate to the class-based tool + expect(applyDiffToolClass.handle).toHaveBeenCalled() expect(mockHandleError).not.toHaveBeenCalled() }) }) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index d22c163636..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 @@ -380,9 +387,9 @@ describe("read_file tool with maxReadFileLine setting", () => { // Execute const result = await executeReadFileTool({}, { maxReadFileLine: -1 }) - // Verify - just check that the result contains the expected elements - expect(result).toContain(`${testFilePath}`) - expect(result).toContain(``) + // Verify - check that the result contains the expected native format elements + expect(result).toContain(`File: ${testFilePath}`) + expect(result).toContain(`Lines 1-5:`) }) it("should not show line snippet in approval message when maxReadFileLine is -1", async () => { @@ -418,17 +425,14 @@ describe("read_file tool with maxReadFileLine setting", () => { }, ) - // Verify - expect(result).toContain(`${testFilePath}`) - expect(result).toContain(``) + // Verify - native format + expect(result).toContain(`File: ${testFilePath}`) + expect(result).toContain(`Code Definitions:`) - // Verify XML structure - expect(result).toContain("Showing only 0 of 5 total lines") - expect(result).toContain("") - expect(result).toContain("") + // Verify native structure + expect(result).toContain("Note: Showing only 0 of 5 total lines") expect(result).toContain(sourceCodeDef.trim()) - expect(result).toContain("") - expect(result).not.toContain(" { // Execute const result = await executeReadFileTool({}, { maxReadFileLine: 3 }) - // Verify - just check that the result contains the expected elements - expect(result).toContain(`${testFilePath}`) - expect(result).toContain(``) - expect(result).toContain(``) - expect(result).toContain("Showing only 3 of 5 total lines") + // Verify - native format + expect(result).toContain(`File: ${testFilePath}`) + expect(result).toContain(`Lines 1-3:`) + expect(result).toContain(`Code Definitions:`) + expect(result).toContain("Note: Showing only 3 of 5 total lines") }) it("should truncate code definitions when file exceeds maxReadFileLine", async () => { @@ -471,17 +475,17 @@ describe("read_file tool with maxReadFileLine setting", () => { // Execute with maxReadFileLine = 30 const result = await executeReadFileTool({}, { maxReadFileLine: 30, totalLines: 100 }) - // Verify that only definitions within the first 30 lines are included - expect(result).toContain(`${testFilePath}`) - expect(result).toContain(``) - expect(result).toContain(``) + // Verify - native format + expect(result).toContain(`File: ${testFilePath}`) + expect(result).toContain(`Lines 1-30:`) + expect(result).toContain(`Code Definitions:`) // Should include foo (starts at line 10) but not bar (starts at line 50) or baz (starts at line 80) expect(result).toContain("10--20 | function foo()") expect(result).not.toContain("50--60 | function bar()") expect(result).not.toContain("80--90 | function baz()") - expect(result).toContain("Showing only 30 of 100 total lines") + expect(result).toContain("Note: Showing only 30 of 100 total lines") }) it("should handle truncation when all definitions are beyond the line limit", async () => { @@ -499,10 +503,10 @@ describe("read_file tool with maxReadFileLine setting", () => { // Execute with maxReadFileLine = 30 const result = await executeReadFileTool({}, { maxReadFileLine: 30, totalLines: 100 }) - // Verify that only the header is included (all definitions filtered out) - expect(result).toContain(`${testFilePath}`) - expect(result).toContain(``) - expect(result).toContain(``) + // Verify - native format + expect(result).toContain(`File: ${testFilePath}`) + expect(result).toContain(`Lines 1-30:`) + expect(result).toContain(`Code Definitions:`) expect(result).toContain("# file.txt") expect(result).not.toContain("50--60 | function foo()") expect(result).not.toContain("80--90 | function bar()") @@ -518,9 +522,9 @@ describe("read_file tool with maxReadFileLine setting", () => { // Execute const result = await executeReadFileTool({}, { maxReadFileLine: 10, totalLines: 5 }) - // Verify - just check that the result contains the expected elements - expect(result).toContain(`${testFilePath}`) - expect(result).toContain(``) + // Verify - native format + expect(result).toContain(`File: ${testFilePath}`) + expect(result).toContain(`Lines 1-5:`) }) it("should read with extractTextFromFile when file has few lines", async () => { @@ -540,9 +544,9 @@ describe("read_file tool with maxReadFileLine setting", () => { // Execute const result = await executeReadFileTool({}, { maxReadFileLine: 5, totalLines: 3 }) - // Verify - just check that the result contains the expected elements - expect(result).toContain(`${testFilePath}`) - expect(result).toContain(``) + // Verify - native format + expect(result).toContain(`File: ${testFilePath}`) + expect(result).toContain(`Lines 1-3:`) }) }) @@ -556,8 +560,8 @@ describe("read_file tool with maxReadFileLine setting", () => { // Execute const result = await executeReadFileTool({}, { maxReadFileLine: 3, totalLines: 3 }) - // Verify - just check basic structure, the actual binary handling may vary - expect(result).toContain(`${testFilePath}`) + // Verify - native format for binary files + expect(result).toContain(`File: ${testFilePath}`) expect(typeof result).toBe("string") }) }) @@ -576,14 +580,14 @@ describe("read_file tool with maxReadFileLine setting", () => { }, ) - // Verify - just check that the result contains the expected elements - expect(rangeResult).toContain(`${testFilePath}`) - expect(rangeResult).toContain(``) + // Verify - native format + expect(rangeResult).toContain(`File: ${testFilePath}`) + expect(rangeResult).toContain(`Lines 2-4:`) }) }) }) -describe("read_file tool XML output structure", () => { +describe("read_file tool output structure", () => { // Test basic XML structure const testFilePath = "test/file.txt" const absoluteFilePath = "/test/file.txt" @@ -615,7 +619,12 @@ describe("read_file tool XML 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 @@ -691,8 +700,8 @@ describe("read_file tool XML output structure", () => { return toolResult } - describe("Basic XML Structure Tests", () => { - it("should produce XML output with no unnecessary indentation", async () => { + describe("Basic Structure Tests", () => { + it("should produce native output with proper format", async () => { // Setup const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5" @@ -713,24 +722,19 @@ describe("read_file tool XML output structure", () => { // Execute const result = await executeReadFileTool() - // Verify - expect(result).toBe( - `\n${testFilePath}\n\n${numberedContent}\n\n`, - ) + // Verify native format + expect(result).toBe(`File: ${testFilePath}\nLines 1-5:\n${numberedContent}`) }) - it("should follow the correct XML structure format", async () => { + it("should follow the correct native structure format", async () => { // Setup mockInputContent = fileContent // Execute const result = await executeReadFileTool({}, { maxReadFileLine: -1 }) - // Verify using regex to check structure - const xmlStructureRegex = new RegExp( - `^\\n${testFilePath}\\n\\n.*\\n\\n$`, - "s", - ) - expect(result).toMatch(xmlStructureRegex) + // Verify using regex to check native structure + const nativeStructureRegex = new RegExp(`^File: ${testFilePath}\\nLines 1-5:\\n.*$`, "s") + expect(result).toMatch(nativeStructureRegex) }) it("should handle empty files correctly", async () => { @@ -754,10 +758,8 @@ describe("read_file tool XML output structure", () => { // Execute const result = await executeReadFileTool({}, { totalLines: 0 }) - // Verify - expect(result).toBe( - `\n${testFilePath}\nFile is empty\n\n`, - ) + // Verify native format for empty file + expect(result).toBe(`File: ${testFilePath}\nNote: File is empty`) }) describe("Total Image Memory Limit", () => { @@ -862,7 +864,7 @@ describe("read_file tool XML 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 @@ -938,7 +940,7 @@ describe("read_file tool XML 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 @@ -1022,9 +1024,9 @@ describe("read_file tool XML 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 @@ -1095,7 +1097,7 @@ describe("read_file tool XML 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 @@ -1149,9 +1151,9 @@ describe("read_file tool XML 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) @@ -1211,7 +1213,7 @@ describe("read_file tool XML 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 @@ -1264,7 +1266,7 @@ describe("read_file tool XML 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==", @@ -1313,7 +1315,7 @@ describe("read_file tool XML 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 @@ -1360,7 +1362,7 @@ describe("read_file tool XML 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 @@ -1381,7 +1383,7 @@ describe("read_file tool XML 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}`) @@ -1406,7 +1408,7 @@ describe("read_file tool XML output structure", () => { }) describe("Error Handling Tests", () => { - it("should include error tag for invalid path", async () => { + it("should include error in output for invalid path", async () => { // Setup - missing path parameter const toolUse: ReadFileToolUse = { type: "tool_use", @@ -1426,19 +1428,50 @@ describe("read_file tool XML output structure", () => { toolProtocol: "xml", }) - // Verify - expect(toolResult).toBe(`Missing required parameter`) + // Verify - native format for error + expect(toolResult).toBe(`Error: Missing required parameter`) }) - it("should include error tag for RooIgnore error", async () => { + it("should include error for RooIgnore error", async () => { // Execute - skip addLineNumbers check as it returns early with an error const result = await executeReadFileTool({}, { validateAccess: false }) - // Verify + // Verify - native format for error expect(result).toBe( - `\n${testFilePath}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.\n`, + `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")) + }) }) }) @@ -1470,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() @@ -1548,10 +1586,10 @@ describe("read_file tool with image support", () => { const textPart = (result as any[]).find((p) => p.type === "text")?.text const imagePart = (result as any[]).find((p) => p.type === "image") - // Verify text part - expect(textPart).toContain(`${imagePath}`) + // Verify text part - native format + expect(textPart).toContain(`File: ${imagePath}`) expect(textPart).not.toContain("") - expect(textPart).toContain(`Image file`) + expect(textPart).toContain(`Note: Image file`) // Verify image part expect(imagePart).toBeDefined() @@ -1570,10 +1608,10 @@ describe("read_file tool with image support", () => { const textPart = (result as any[]).find((p) => p.type === "text")?.text const imagePart = (result as any[]).find((p) => p.type === "image") - // Verify text part - expect(textPart).toContain(`${testImagePath}`) + // Verify text part - native format + expect(textPart).toContain(`File: ${testImagePath}`) expect(textPart).not.toContain(``) - expect(textPart).toContain(`Image file`) + expect(textPart).toContain(`Note: Image file`) // Verify image part expect(imagePart).toBeDefined() @@ -1591,7 +1629,8 @@ describe("read_file tool with image support", () => { const textArg = callArgs[0] const imagesArg = callArgs[1] - expect(textArg).toContain(`${testImagePath}`) + // Native format + expect(textArg).toContain(`File: ${testImagePath}`) expect(imagesArg).toBeDefined() expect(imagesArg).toBeInstanceOf(Array) expect(imagesArg!.length).toBe(1) @@ -1622,11 +1661,12 @@ describe("read_file tool with image support", () => { // Execute const result = await executeReadImageTool() - // When images are not supported, the tool should return just XML (not call formatResponse.toolResult) + // When images are not supported, the tool should return just text (not call formatResponse.toolResult) expect(toolResultMock).not.toHaveBeenCalled() expect(typeof result).toBe("string") - expect(result).toContain(`${testImagePath}`) - expect(result).toContain(`Image file`) + // Native format + expect(result).toContain(`File: ${testImagePath}`) + expect(result).toContain(`Note: Image file`) }) it("should include images when model supports images", async () => { @@ -1642,7 +1682,8 @@ describe("read_file tool with image support", () => { const textArg = callArgs[0] const imagesArg = callArgs[1] - expect(textArg).toContain(`${testImagePath}`) + // Native format + expect(textArg).toContain(`File: ${testImagePath}`) expect(imagesArg).toBeDefined() // Images should be included expect(imagesArg).toBeInstanceOf(Array) expect(imagesArg!.length).toBe(1) @@ -1656,11 +1697,12 @@ describe("read_file tool with image support", () => { // Execute const result = await executeReadImageTool() - // When supportsImages is undefined, should default to false and return just XML + // When supportsImages is undefined, should default to false and return just text expect(toolResultMock).not.toHaveBeenCalled() expect(typeof result).toBe("string") - expect(result).toContain(`${testImagePath}`) - expect(result).toContain(`Image file`) + // Native format + expect(result).toContain(`File: ${testImagePath}`) + expect(result).toContain(`Note: Image file`) }) it("should handle errors when reading image files", async () => { @@ -1686,8 +1728,8 @@ describe("read_file tool with image support", () => { toolProtocol: "xml", }) - // Verify error handling - expect(toolResult).toContain("Error reading image file: Failed to read image") + // Verify error handling - native format + expect(toolResult).toContain("Error: Error reading image file: Failed to read image") // Verify that say was called to show error to user expect(localMockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Failed to read image")) }) @@ -1722,9 +1764,9 @@ describe("read_file tool with image support", () => { // Execute const result = await executeReadImageTool(binaryPath) - // Verify + // Verify - native format for binary files expect(result).not.toContain("") - expect(result).toContain(' { }) }) }) + +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__/searchAndReplaceTool.spec.ts b/src/core/tools/__tests__/searchAndReplaceTool.spec.ts new file mode 100644 index 0000000000..c73744ec57 --- /dev/null +++ b/src/core/tools/__tests__/searchAndReplaceTool.spec.ts @@ -0,0 +1,405 @@ +import * as path from "path" +import fs from "fs/promises" + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import { getReadablePath } from "../../../utils/path" +import { ToolUse, ToolResponse } from "../../../shared/tools" +import { searchAndReplaceTool } from "../SearchAndReplaceTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue(""), + }, +})) + +vi.mock("path", async () => { + const originalPath = await vi.importActual("path") + return { + ...originalPath, + resolve: vi.fn().mockImplementation((...args) => { + const separator = process.platform === "win32" ? "\\" : "/" + return args.join(separator) + }), + isAbsolute: vi.fn().mockReturnValue(false), + relative: vi.fn().mockImplementation((from, to) => to), + } +}) + +vi.mock("delay", () => ({ + default: vi.fn(), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg) => `Error: ${msg}`), + rooIgnoreError: vi.fn((path) => `Access denied: ${path}`), + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("../../../utils/pathUtils", () => ({ + isPathOutsideWorkspace: vi.fn().mockReturnValue(false), +})) + +vi.mock("../../../utils/path", () => ({ + getReadablePath: vi.fn().mockReturnValue("test/path.txt"), +})) + +vi.mock("../../diff/stats", () => ({ + sanitizeUnifiedDiff: vi.fn((diff) => diff), + computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), +})) + +vi.mock("vscode", () => ({ + window: { + showWarningMessage: vi.fn().mockResolvedValue(undefined), + }, + env: { + openExternal: vi.fn(), + }, + Uri: { + parse: vi.fn(), + }, +})) + +describe("searchAndReplaceTool", () => { + // Test data + const testFilePath = "test/file.txt" + const absoluteFilePath = process.platform === "win32" ? "C:\\test\\file.txt" : "/test/file.txt" + const testFileContent = "Line 1\nLine 2\nLine 3\nLine 4" + + // Mocked functions + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + const mockedFsReadFile = fs.readFile as unknown as MockedFunction< + (path: string, encoding: string) => Promise + > + const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction + const mockedGetReadablePath = getReadablePath as MockedFunction + const mockedPathResolve = path.resolve as MockedFunction + const mockedPathIsAbsolute = path.isAbsolute as MockedFunction + + const mockTask: any = {} + let mockAskApproval: ReturnType + let mockHandleError: ReturnType + let mockPushToolResult: ReturnType + let mockRemoveClosingTag: ReturnType + let toolResult: ToolResponse | undefined + + beforeEach(() => { + vi.clearAllMocks() + + mockedPathResolve.mockReturnValue(absoluteFilePath) + mockedPathIsAbsolute.mockReturnValue(false) + mockedFileExistsAtPath.mockResolvedValue(true) + mockedFsReadFile.mockResolvedValue(testFileContent) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + mockedGetReadablePath.mockReturnValue("test/path.txt") + + mockTask.cwd = "/" + mockTask.consecutiveMistakeCount = 0 + mockTask.didEditFile = false + mockTask.providerRef = { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }), + } + mockTask.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockTask.rooProtectedController = { + isWriteProtected: vi.fn().mockReturnValue(false), + } + mockTask.diffViewProvider = { + editType: undefined, + isEditing: false, + originalContent: "", + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + revertChanges: vi.fn().mockResolvedValue(undefined), + saveChanges: vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: null, + finalContent: "final content", + }), + saveDirectly: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Tool result message"), + } + mockTask.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + mockTask.say = vi.fn().mockResolvedValue(undefined) + mockTask.ask = vi.fn().mockResolvedValue(undefined) + mockTask.recordToolError = vi.fn() + mockTask.recordToolUsage = vi.fn() + mockTask.processQueuedMessages = vi.fn() + mockTask.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockRemoveClosingTag = vi.fn((tag, content) => content) + + toolResult = undefined + }) + + /** + * Helper function to execute the search and replace tool with different parameters + */ + async function executeSearchAndReplaceTool( + params: Partial = {}, + options: { + fileExists?: boolean + fileContent?: string + isPartial?: boolean + accessAllowed?: boolean + } = {}, + ): Promise { + const fileExists = options.fileExists ?? true + const fileContent = options.fileContent ?? testFileContent + const isPartial = options.isPartial ?? false + const accessAllowed = options.accessAllowed ?? true + + mockedFileExistsAtPath.mockResolvedValue(fileExists) + mockedFsReadFile.mockResolvedValue(fileContent) + mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + + const toolUse: ToolUse = { + type: "tool_use", + name: "search_and_replace", + params: { + path: testFilePath, + operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]), + ...params, + }, + partial: isPartial, + } + + mockPushToolResult = vi.fn((result: ToolResponse) => { + toolResult = result + }) + + await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"search_and_replace">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "native", + }) + + return toolResult + } + + describe("parameter validation", () => { + it("returns error when path is missing", async () => { + const result = await executeSearchAndReplaceTool({ path: undefined }) + + expect(result).toBe("Missing param error") + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("search_and_replace") + }) + + it("returns error when operations is missing", async () => { + const result = await executeSearchAndReplaceTool({ operations: undefined }) + + expect(result).toContain("Error:") + expect(result).toContain("Missing or empty 'operations' parameter") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("returns error when operations is empty array", async () => { + const result = await executeSearchAndReplaceTool({ operations: JSON.stringify([]) }) + + expect(result).toContain("Error:") + expect(result).toContain("Missing or empty 'operations' parameter") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + }) + + describe("file access", () => { + it("returns error when file does not exist", async () => { + const result = await executeSearchAndReplaceTool({}, { fileExists: false }) + + expect(result).toContain("Error:") + expect(result).toContain("File not found") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("returns error when access is denied", async () => { + const result = await executeSearchAndReplaceTool({}, { accessAllowed: false }) + + expect(result).toContain("Access denied") + }) + }) + + describe("search and replace logic", () => { + it("returns error when no match is found", async () => { + const result = await executeSearchAndReplaceTool( + { operations: JSON.stringify([{ search: "NonExistent", replace: "New" }]) }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(result).toContain("Error:") + expect(result).toContain("No match found") + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("search_and_replace", "no_match") + }) + + it("returns error when multiple matches are found", async () => { + const result = await executeSearchAndReplaceTool( + { operations: JSON.stringify([{ search: "Line", replace: "Row" }]) }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(result).toContain("Error:") + expect(result).toContain("3 matches") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("successfully replaces single unique match", async () => { + await executeSearchAndReplaceTool( + { operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]) }, + { fileContent: "Line 1\nLine 2\nLine 3" }, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockTask.diffViewProvider.editType).toBe("modify") + expect(mockAskApproval).toHaveBeenCalled() + }) + }) + + describe("CRLF normalization", () => { + it("normalizes CRLF to LF when reading file", async () => { + const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" + + await executeSearchAndReplaceTool( + { operations: JSON.stringify([{ search: "Line 2", replace: "Modified Line 2" }]) }, + { fileContent: contentWithCRLF }, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("normalizes CRLF in search string to match LF-normalized file content", async () => { + // File has CRLF line endings + const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" + // Search string also has CRLF (simulating what the model might send) + const searchWithCRLF = "Line 1\r\nLine 2" + + await executeSearchAndReplaceTool( + { operations: JSON.stringify([{ search: searchWithCRLF, replace: "Modified Lines" }]) }, + { fileContent: contentWithCRLF }, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("matches LF search string against CRLF file content after normalization", async () => { + // File has CRLF line endings + const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" + // Search string has LF (typical model output) + const searchWithLF = "Line 1\nLine 2" + + await executeSearchAndReplaceTool( + { operations: JSON.stringify([{ search: searchWithLF, replace: "Modified Lines" }]) }, + { fileContent: contentWithCRLF }, + ) + + expect(mockTask.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + }) + }) + + describe("approval workflow", () => { + it("saves changes when user approves", async () => { + mockAskApproval.mockResolvedValue(true) + + await executeSearchAndReplaceTool() + + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("search_and_replace") + }) + + it("reverts changes when user rejects", async () => { + mockAskApproval.mockResolvedValue(false) + + const result = await executeSearchAndReplaceTool() + + expect(mockTask.diffViewProvider.revertChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(result).toContain("rejected") + }) + }) + + describe("partial block handling", () => { + it("handles partial block without errors", async () => { + await executeSearchAndReplaceTool({}, { isPartial: true }) + + expect(mockTask.ask).toHaveBeenCalled() + }) + }) + + describe("error handling", () => { + it("handles file read errors gracefully", async () => { + mockedFsReadFile.mockRejectedValueOnce(new Error("Read failed")) + + const toolUse: ToolUse = { + type: "tool_use", + name: "search_and_replace", + params: { + path: testFilePath, + operations: JSON.stringify([{ search: "Line 2", replace: "Modified" }]), + }, + partial: false, + } + + let capturedResult: ToolResponse | undefined + const localPushToolResult = vi.fn((result: ToolResponse) => { + capturedResult = result + }) + + await searchAndReplaceTool.handle(mockTask, toolUse as ToolUse<"search_and_replace">, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: localPushToolResult, + removeClosingTag: mockRemoveClosingTag, + toolProtocol: "native", + }) + + expect(capturedResult).toContain("Error:") + expect(capturedResult).toContain("Failed to read file") + expect(mockTask.consecutiveMistakeCount).toBe(1) + }) + + it("handles general errors and resets diff view", async () => { + mockTask.diffViewProvider.open.mockRejectedValueOnce(new Error("General error")) + + await executeSearchAndReplaceTool() + + expect(mockHandleError).toHaveBeenCalledWith("search and replace", expect.any(Error)) + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + }) + }) + + describe("file tracking", () => { + it("tracks file context after successful edit", async () => { + await executeSearchAndReplaceTool() + + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") + }) + }) +}) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index b9b1e453d4..984808e971 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -379,4 +379,48 @@ describe("searchReplaceTool", () => { expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") }) }) + + describe("CRLF normalization", () => { + it("normalizes CRLF to LF when reading file", async () => { + const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" + + await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: contentWithCRLF }, + ) + + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("normalizes CRLF in old_string to match LF-normalized file content", async () => { + // File has CRLF line endings + const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" + // Search string also has CRLF (simulating what the model might send) + const searchWithCRLF = "Line 1\r\nLine 2" + + await executeSearchReplaceTool( + { old_string: searchWithCRLF, new_string: "Modified Lines" }, + { fileContent: contentWithCRLF }, + ) + + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + }) + + it("matches LF old_string against CRLF file content after normalization", async () => { + // File has CRLF line endings + const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" + // Search string has LF (typical model output) + const searchWithLF = "Line 1\nLine 2" + + await executeSearchReplaceTool( + { old_string: searchWithLF, new_string: "Modified Lines" }, + { fileContent: contentWithCRLF }, + ) + + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(mockAskApproval).toHaveBeenCalled() + }) + }) }) 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/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index d057033741..751d164fd2 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -1,5 +1,6 @@ import type { ToolName, ModeConfig, ExperimentId, GroupOptions, GroupEntry } from "@roo-code/types" import { toolNames as validToolNames } from "@roo-code/types" +import { customToolRegistry } from "@roo-code/core" import { type Mode, FileRestrictionError, getModeBySlug, getGroupName } from "../../shared/modes" import { EXPERIMENT_IDS } from "../../shared/experiments" @@ -10,12 +11,16 @@ import { TOOL_GROUPS, ALWAYS_AVAILABLE_TOOLS } from "../../shared/tools" * Note: This does NOT check if the tool is allowed for a specific mode, * only that the tool actually exists. */ -export function isValidToolName(toolName: string): toolName is ToolName { +export function isValidToolName(toolName: string, experiments?: Record): toolName is ToolName { // Check if it's a valid static tool if ((validToolNames as readonly string[]).includes(toolName)) { return true } + if (experiments?.customTools && customToolRegistry.has(toolName)) { + return true + } + // Check if it's a dynamic MCP tool (mcp_serverName_toolName format). if (toolName.startsWith("mcp_")) { return true @@ -35,7 +40,7 @@ export function validateToolUse( ): void { // First, check if the tool name is actually a valid/known tool // This catches completely invalid tool names like "edit_file" that don't exist - if (!isValidToolName(toolName)) { + if (!isValidToolName(toolName, experiments)) { throw new Error( `Unknown tool "${toolName}". This tool does not exist. Please use one of the available tools: ${validToolNames.join(", ")}.`, ) @@ -87,6 +92,12 @@ export function isToolAllowedForMode( return true } + // For now, allow all custom tools in any mode. + // As a follow-up we should expand the custom tool definition to include mode restrictions. + if (experiments?.customTools && customToolRegistry.has(tool)) { + return true + } + // Check if this is a dynamic MCP tool (mcp_serverName_toolName) // These should be allowed if the mcp group is allowed for the mode const isDynamicMcpTool = tool.startsWith("mcp_") diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5cf6c6611b..a34fb817ee 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.36.0-context-rewind-roo-provider" // v3.36.0 Context Rewind & Roo Provider Improvements + public readonly latestAnnouncementId = "dec-2025-v3.38.0-skills-native-tool-calling" // v3.38.0 Skills & Native Tool Calling Required 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") @@ -1863,6 +1873,7 @@ export class ClineProvider browserToolEnabled, telemetrySetting, showRooIgnoredFiles, + enableSubfolderRules, language, maxReadFileLine, maxImageFileSize, @@ -1896,7 +1907,6 @@ export class ClineProvider imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, isBrowserSessionActive, } = await this.getState() @@ -2011,6 +2021,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 +2036,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 +2078,6 @@ export class ClineProvider imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, claudeCodeIsAuthenticated: await (async () => { try { @@ -2258,10 +2269,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 +2453,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..6c125f490d 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, 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/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 aa72ee253f..35bf08e048 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2,6 +2,7 @@ import { safeWriteJson } from "../../utils/safeWriteJson" import * as path from "path" import * as os from "os" import * as fs from "fs/promises" +import { getRooDirectoriesForCwd } from "../../services/roo-config/index.js" import pWaitFor from "p-wait-for" import * as vscode from "vscode" @@ -13,9 +14,9 @@ import { type UserSettingsConfig, TelemetryEventName, RooCodeSettings, - Experiments, ExperimentId, } from "@roo-code/types" +import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" @@ -789,6 +790,9 @@ export const webviewMessageHandler = async ( const requestedProvider = message?.values?.provider const providerFilter = requestedProvider ? toRouterName(requestedProvider) : undefined + // Optional refresh flag to flush cache before fetching (useful for providers requiring credentials) + const shouldRefresh = message?.values?.refresh === true + const routerModels: Record = providerFilter ? ({} as Record) : { @@ -886,6 +890,12 @@ export const webviewMessageHandler = async ( ? candidates.filter(({ key }) => key === providerFilter) : candidates + // If refresh flag is set and we have a specific provider, flush its cache first + if (shouldRefresh && providerFilter && modelFetchPromises.length > 0) { + const targetCandidate = modelFetchPromises[0] + await flushModels(targetCandidate.options, true) + } + const results = await Promise.allSettled( modelFetchPromises.map(async ({ key, options }) => { const models = await safeGetModels(options) @@ -1725,6 +1735,25 @@ export const webviewMessageHandler = async ( } break } + case "refreshCustomTools": { + try { + const toolDirs = getRooDirectoriesForCwd(getCurrentCwd()).map((dir) => path.join(dir, "tools")) + await customToolRegistry.loadFromDirectories(toolDirs) + + await provider.postMessageToWebview({ + type: "customToolsResult", + tools: customToolRegistry.getAllSerialized(), + }) + } catch (error) { + await provider.postMessageToWebview({ + type: "customToolsResult", + tools: [], + error: error instanceof Error ? error.message : String(error), + }) + } + + break + } case "saveApiConfiguration": if (message.text && message.apiConfiguration) { try { @@ -2189,25 +2218,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" @@ -2355,6 +2365,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 diff --git a/src/esbuild.mjs b/src/esbuild.mjs index f99b077e9f..aabacfcee9 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -15,7 +15,7 @@ async function main() { const production = process.argv.includes("--production") const watch = process.argv.includes("--watch") const minify = production - const sourcemap = true // Always generate source maps for error handling + const sourcemap = true // Always generate source maps for error handling. /** * @type {import('esbuild').BuildOptions} @@ -100,7 +100,10 @@ async function main() { plugins, entryPoints: ["extension.ts"], outfile: "dist/extension.js", - external: ["vscode"], + // 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 f601b9d7cf..76f02af6de 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,9 +15,11 @@ try { import type { CloudUserInfo, AuthState } from "@roo-code/types" import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" +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" @@ -67,6 +69,14 @@ 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) + // Migrate old settings to new await migrateSettings(context, outputChannel) @@ -92,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 3ffd6b821d..e6b6cd4181 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.36.16", + "version": "3.38.3", "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%" } } } @@ -441,6 +459,7 @@ "@modelcontextprotocol/sdk": "1.12.0", "@qdrant/js-client-rest": "^1.14.0", "@roo-code/cloud": "workspace:^", + "@roo-code/core": "workspace:^", "@roo-code/ipc": "workspace:^", "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^", @@ -460,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", @@ -502,6 +522,7 @@ "tmp": "^0.2.3", "tree-sitter-wasms": "^0.1.13", "turndown": "^7.2.0", + "undici": "^6.21.3", "uuid": "^11.1.0", "vscode-material-icons": "^0.1.1", "web-tree-sitter": "^0.25.6", @@ -534,7 +555,7 @@ "@types/vscode": "^1.84.0", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", - "esbuild": "^0.25.0", + "esbuild-wasm": "^0.25.0", "execa": "^9.5.2", "glob": "^11.1.0", "mkdirp": "^3.0.1", 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 20bb575964..2eec4cb6c8 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -14,6 +14,7 @@ import type { OrganizationAllowList, ShareVisibility, QueuedMessage, + SerializedCustomToolDefinition, } from "@roo-code/types" import { GitCommit } from "../utils/git" @@ -93,9 +94,6 @@ export interface ExtensionMessage { | "deleteCustomModeCheck" | "currentCheckpointUpdated" | "checkpointInitWarning" - | "showHumanRelayDialog" - | "humanRelayResponse" - | "humanRelayCancel" | "browserToolEnabled" | "browserConnectionResult" | "remoteBrowserEnabled" @@ -133,6 +131,7 @@ export interface ExtensionMessage { | "browserSessionUpdate" | "browserSessionNavigate" | "claudeCodeRateLimits" + | "customToolsResult" text?: string payload?: any // Add a generic payload for now, can refine later // Checkpoint warning message @@ -218,6 +217,7 @@ export interface ExtensionMessage { browserSessionMessages?: ClineMessage[] // For browser session panel updates isBrowserSessionActive?: boolean // For browser session panel updates stepIndex?: number // For browserSessionNavigate: the target step index to display + tools?: SerializedCustomToolDefinition[] // For customToolsResult } export type ExtensionState = Pick< @@ -308,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 @@ -332,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 @@ -348,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 970208a2a3..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" @@ -180,6 +179,7 @@ export interface WebviewMessage { | "openDebugUiHistory" | "downloadErrorDiagnostics" | "requestClaudeCodeRateLimits" + | "refreshCustomTools" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" 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/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 23116b19b2..0b43302611 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -32,6 +32,7 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, multipleNativeToolCalls: false, + customTools: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -44,6 +45,7 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, multipleNativeToolCalls: false, + customTools: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) }) @@ -56,6 +58,7 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, multipleNativeToolCalls: false, + customTools: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(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/experiments.ts b/src/shared/experiments.ts index 0b11edfcdf..ad3aeca863 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -7,6 +7,7 @@ export const EXPERIMENT_IDS = { IMAGE_GENERATION: "imageGeneration", RUN_SLASH_COMMAND: "runSlashCommand", MULTIPLE_NATIVE_TOOL_CALLS: "multipleNativeToolCalls", + CUSTOM_TOOLS: "customTools", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -24,6 +25,7 @@ export const experimentConfigsMap: Record = { IMAGE_GENERATION: { enabled: false }, RUN_SLASH_COMMAND: { enabled: false }, MULTIPLE_NATIVE_TOOL_CALLS: { enabled: false }, + CUSTOM_TOOLS: { enabled: false }, } export const experimentDefault = Object.fromEntries( 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__/autoImportSettings.spec.ts b/src/utils/__tests__/autoImportSettings.spec.ts index be0d769670..f3911571d1 100644 --- a/src/utils/__tests__/autoImportSettings.spec.ts +++ b/src/utils/__tests__/autoImportSettings.spec.ts @@ -17,15 +17,33 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn(), })) -vi.mock("path", () => ({ - join: vi.fn((...args: string[]) => args.join("/")), - isAbsolute: vi.fn((p: string) => p.startsWith("/")), - basename: vi.fn((p: string) => p.split("/").pop() || ""), -})) +vi.mock("path", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + ...actual, + join: vi.fn((...args: string[]) => args.join("/")), + isAbsolute: vi.fn((p: string) => p.startsWith("/")), + basename: vi.fn((p: string) => p.split("/").pop() || ""), + }, + join: vi.fn((...args: string[]) => args.join("/")), + isAbsolute: vi.fn((p: string) => p.startsWith("/")), + basename: vi.fn((p: string) => p.split("/").pop() || ""), + } +}) -vi.mock("os", () => ({ - homedir: vi.fn(() => "/home/user"), -})) +vi.mock("os", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + ...actual, + homedir: vi.fn(() => "/home/user"), + }, + homedir: vi.fn(() => "/home/user"), + } +}) vi.mock("../fs", () => ({ fileExistsAtPath: vi.fn(), diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index c53e0d7b86..5a1510be43 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -26,10 +26,10 @@ describe("normalizeToolSchema", () => { const result = normalizeToolSchema(input) - // additionalProperties should NOT be added to array or primitive types + // Array-specific properties (items) should be moved inside the array variant + // This is required by strict schema validators like GPT-5-mini expect(result).toEqual({ - anyOf: [{ type: "array" }, { type: "null" }], - items: { type: "string" }, + anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], description: "Optional array", }) }) @@ -97,6 +97,7 @@ describe("normalizeToolSchema", () => { const result = normalizeToolSchema(input) // additionalProperties: false should ONLY be on object types + // Array-specific properties (items) should be moved inside the array variant expect(result).toEqual({ type: "array", items: { @@ -104,8 +105,7 @@ describe("normalizeToolSchema", () => { properties: { path: { type: "string" }, line_ranges: { - anyOf: [{ type: "array" }, { type: "null" }], - items: { type: "integer" }, + anyOf: [{ type: "array", items: { type: "integer" } }, { type: "null" }], }, }, additionalProperties: false, @@ -143,7 +143,11 @@ describe("normalizeToolSchema", () => { const properties = result.properties as Record> const filesItems = properties.files.items as Record const filesItemsProps = filesItems.properties as Record> - expect(filesItemsProps.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }]) + // Array-specific properties (items) should be moved inside the array variant + expect(filesItemsProps.line_ranges.anyOf).toEqual([ + { type: "array", items: { type: "array", items: { type: "integer" } } }, + { type: "null" }, + ]) }) it("should recursively transform anyOf arrays", () => { @@ -255,13 +259,26 @@ describe("normalizeToolSchema", () => { const result = normalizeToolSchema(input) - // Verify the line_ranges was transformed + // Verify the line_ranges was transformed with items inside the array variant const files = (result.properties as Record).files as Record const items = files.items as Record const props = items.properties as Record> - expect(props.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }]) - // Verify other properties are preserved - expect(props.line_ranges.items).toBeDefined() + // Array-specific properties (items, minItems, maxItems) should be moved inside the array variant + expect(props.line_ranges.anyOf).toEqual([ + { + type: "array", + items: { + type: "array", + items: { type: "integer" }, + minItems: 2, + maxItems: 2, + }, + }, + { type: "null" }, + ]) + // items should NOT be at root level anymore + expect(props.line_ranges.items).toBeUndefined() + // Other properties are preserved at root level expect(props.line_ranges.description).toBe("Optional line ranges") }) diff --git a/src/utils/__tests__/mcp-name.spec.ts b/src/utils/__tests__/mcp-name.spec.ts index b28c2e504c..5511893f79 100644 --- a/src/utils/__tests__/mcp-name.spec.ts +++ b/src/utils/__tests__/mcp-name.spec.ts @@ -1,4 +1,11 @@ -import { sanitizeMcpName, buildMcpToolName, parseMcpToolName, MCP_TOOL_SEPARATOR, MCP_TOOL_PREFIX } from "../mcp-name" +import { + sanitizeMcpName, + buildMcpToolName, + parseMcpToolName, + isMcpTool, + MCP_TOOL_SEPARATOR, + MCP_TOOL_PREFIX, +} from "../mcp-name" describe("mcp-name utilities", () => { describe("constants", () => { @@ -8,6 +15,29 @@ describe("mcp-name utilities", () => { }) }) + describe("isMcpTool", () => { + it("should return true for valid MCP tool names", () => { + expect(isMcpTool("mcp--server--tool")).toBe(true) + expect(isMcpTool("mcp--my_server--get_forecast")).toBe(true) + }) + + it("should return false for non-MCP tool names", () => { + expect(isMcpTool("server--tool")).toBe(false) + expect(isMcpTool("tool")).toBe(false) + expect(isMcpTool("read_file")).toBe(false) + expect(isMcpTool("")).toBe(false) + }) + + it("should return false for old underscore format", () => { + expect(isMcpTool("mcp_server_tool")).toBe(false) + }) + + it("should return false for partial prefix", () => { + expect(isMcpTool("mcp-server")).toBe(false) + expect(isMcpTool("mcp")).toBe(false) + }) + }) + describe("sanitizeMcpName", () => { it("should return underscore placeholder for empty input", () => { expect(sanitizeMcpName("")).toBe("_") 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/__tests__/resolveToolProtocol.spec.ts b/src/utils/__tests__/resolveToolProtocol.spec.ts index 5fbc534438..513a7eaa35 100644 --- a/src/utils/__tests__/resolveToolProtocol.spec.ts +++ b/src/utils/__tests__/resolveToolProtocol.spec.ts @@ -5,269 +5,73 @@ import type { ProviderSettings, ModelInfo } from "@roo-code/types" import type { Anthropic } from "@anthropic-ai/sdk" describe("resolveToolProtocol", () => { - describe("Precedence Level 1: User Profile Setting", () => { - it("should use profile toolProtocol when explicitly set to xml", () => { + /** + * XML Protocol Deprecation: + * + * XML tool protocol has been fully deprecated. All models now use Native + * tool calling. User preferences and model defaults are ignored. + * + * Precedence: + * 1. Locked Protocol (for resumed tasks that used XML) + * 2. Native (always, for all new tasks) + */ + + describe("Locked Protocol (Precedence Level 0 - Highest Priority)", () => { + it("should return lockedProtocol when provided", () => { + const settings: ProviderSettings = { + toolProtocol: "xml", // Ignored + apiProvider: "openai-native", + } + // lockedProtocol overrides everything + const result = resolveToolProtocol(settings, undefined, "native") + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + + it("should return XML lockedProtocol for resumed tasks that used XML", () => { + const settings: ProviderSettings = { + toolProtocol: "native", // Ignored + apiProvider: "anthropic", + } + // lockedProtocol forces XML for backward compatibility + const result = resolveToolProtocol(settings, undefined, "xml") + expect(result).toBe(TOOL_PROTOCOL.XML) + }) + + it("should fall through to Native when lockedProtocol is undefined", () => { + const settings: ProviderSettings = { + toolProtocol: "xml", // Ignored + apiProvider: "anthropic", + } + // undefined lockedProtocol should return native + const result = resolveToolProtocol(settings, undefined, undefined) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + }) + + describe("Native Protocol Always Used For New Tasks", () => { + it("should always use native for new tasks", () => { const settings: ProviderSettings = { - toolProtocol: "xml", apiProvider: "anthropic", } const result = resolveToolProtocol(settings) - expect(result).toBe(TOOL_PROTOCOL.XML) - }) - - it("should use profile toolProtocol when explicitly set to native", () => { - const settings: ProviderSettings = { - toolProtocol: "native", - apiProvider: "anthropic", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: true, // Model supports native tools - } - const result = resolveToolProtocol(settings, modelInfo) expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) - it("should override model default when profile setting is present", () => { + it("should use native even when user preference is XML (user prefs ignored)", () => { const settings: ProviderSettings = { - toolProtocol: "xml", + toolProtocol: "xml", // User wants XML - ignored apiProvider: "openai-native", } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - defaultToolProtocol: "native", - supportsNativeTools: true, - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Profile setting wins - }) - - it("should override model capability when profile setting is present", () => { - const settings: ProviderSettings = { - toolProtocol: "xml", - apiProvider: "openai-native", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: true, - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Profile setting wins - }) - }) - - describe("Precedence Level 2: Model Default", () => { - it("should use model defaultToolProtocol when no profile setting", () => { - const settings: ProviderSettings = { - apiProvider: "roo", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - defaultToolProtocol: "native", - supportsNativeTools: true, // Model must support native tools - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Model default wins when experiment is disabled - }) - - it("should override model capability when model default is present", () => { - const settings: ProviderSettings = { - apiProvider: "roo", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - defaultToolProtocol: "xml", - supportsNativeTools: true, - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Model default wins over capability - }) - }) - - describe("Support Validation", () => { - it("should fall back to XML when model doesn't support native", () => { - const settings: ProviderSettings = { - apiProvider: "anthropic", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: false, - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) - }) - - it("should fall back to XML when user prefers native but model doesn't support it", () => { - const settings: ProviderSettings = { - toolProtocol: "native", // User wants native - apiProvider: "anthropic", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: false, // But model doesn't support it - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Falls back to XML due to lack of support - }) - - it("should fall back to XML when user prefers native but model support is undefined", () => { - const settings: ProviderSettings = { - toolProtocol: "native", // User wants native - apiProvider: "anthropic", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - // supportsNativeTools is undefined (not specified) - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Falls back to XML - undefined treated as unsupported - }) - }) - - describe("Precedence Level 3: Native Fallback", () => { - it("should use Native fallback when no model default is specified and model supports native", () => { - const settings: ProviderSettings = { - apiProvider: "anthropic", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: true, - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Native fallback - }) - }) - - describe("Complete Precedence Chain", () => { - it("should respect full precedence: Profile > Model Default > Native Fallback", () => { - // Set up a scenario with all levels defined - const settings: ProviderSettings = { - toolProtocol: "native", // Level 1: User profile setting - apiProvider: "roo", - } - - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - defaultToolProtocol: "xml", // Level 2: Model default - supportsNativeTools: true, // Support check - } - - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Profile setting wins - }) - - it("should skip to model default when profile setting is undefined", () => { - const settings: ProviderSettings = { - apiProvider: "openai-native", - } - - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - defaultToolProtocol: "xml", // Level 2 - supportsNativeTools: true, // Support check - } - - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Model default wins - }) - - it("should skip to Native fallback when profile and model default are undefined", () => { - const settings: ProviderSettings = { - apiProvider: "openai-native", - } - - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: true, - } - - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Native fallback - }) - - it("should skip to XML fallback when model info is unavailable", () => { - const settings: ProviderSettings = { - apiProvider: "anthropic", - } - - const result = resolveToolProtocol(settings, undefined) - expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback (no model info means no native support) - }) - }) - - describe("Locked Protocol (Precedence Level 0)", () => { - it("should return lockedProtocol when provided, ignoring all other settings", () => { - const settings: ProviderSettings = { - toolProtocol: "xml", // User wants XML - apiProvider: "openai-native", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: true, - defaultToolProtocol: "xml", - } - // lockedProtocol overrides everything - const result = resolveToolProtocol(settings, modelInfo, "native") + const result = resolveToolProtocol(settings) expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) - it("should return XML lockedProtocol even when model supports native", () => { + it("should use native for OpenAI compatible provider", () => { const settings: ProviderSettings = { - toolProtocol: "native", // User wants native - apiProvider: "anthropic", + apiProvider: "openai", } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: true, // Model supports native - defaultToolProtocol: "native", - } - // lockedProtocol forces XML - const result = resolveToolProtocol(settings, modelInfo, "xml") - expect(result).toBe(TOOL_PROTOCOL.XML) - }) - - it("should fall through to normal resolution when lockedProtocol is undefined", () => { - const settings: ProviderSettings = { - toolProtocol: "xml", - apiProvider: "anthropic", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: true, - } - // undefined lockedProtocol should use normal precedence - const result = resolveToolProtocol(settings, modelInfo, undefined) - expect(result).toBe(TOOL_PROTOCOL.XML) // User setting wins + const result = resolveToolProtocol(settings, openAiModelInfoSaneDefaults) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) }) @@ -275,7 +79,7 @@ describe("resolveToolProtocol", () => { it("should handle missing provider name gracefully", () => { const settings: ProviderSettings = {} const result = resolveToolProtocol(settings) - expect(result).toBe(TOOL_PROTOCOL.XML) // Falls back to XML (no model info) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Always native now }) it("should handle undefined model info gracefully", () => { @@ -283,26 +87,18 @@ describe("resolveToolProtocol", () => { apiProvider: "openai-native", } const result = resolveToolProtocol(settings, undefined) - expect(result).toBe(TOOL_PROTOCOL.XML) // XML fallback (no model info) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Always native now }) - it("should fall back to XML when model doesn't support native", () => { - const settings: ProviderSettings = { - apiProvider: "roo", - } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: false, // Model doesn't support native - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Falls back to XML due to lack of support + it("should handle empty settings", () => { + const settings: ProviderSettings = {} + const result = resolveToolProtocol(settings) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Always native now }) }) describe("Real-world Scenarios", () => { - it("should use Native fallback for models without defaultToolProtocol", () => { + it("should use Native for OpenAI models", () => { const settings: ProviderSettings = { apiProvider: "openai-native", } @@ -313,10 +109,10 @@ describe("resolveToolProtocol", () => { supportsNativeTools: true, } const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Native fallback + expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) - it("should use XML for Claude models with Anthropic provider", () => { + it("should use Native for Claude models", () => { const settings: ProviderSettings = { apiProvider: "anthropic", } @@ -324,65 +120,39 @@ describe("resolveToolProtocol", () => { maxTokens: 8192, contextWindow: 200000, supportsPromptCache: true, - supportsNativeTools: false, + supportsNativeTools: true, } const result = resolveToolProtocol(settings, modelInfo) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) + }) + + it("should honor locked protocol for resumed tasks that used XML", () => { + const settings: ProviderSettings = { + apiProvider: "anthropic", + } + // Task was started when XML was used, so it's locked to XML + const result = resolveToolProtocol(settings, undefined, "xml") expect(result).toBe(TOOL_PROTOCOL.XML) }) + }) - it("should allow user to force XML on native-supporting model", () => { + describe("Backward Compatibility - User Preferences Ignored", () => { + it("should ignore user preference for XML", () => { const settings: ProviderSettings = { - toolProtocol: "xml", // User explicitly wants XML + toolProtocol: "xml", // User explicitly wants XML - ignored apiProvider: "openai-native", } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: true, // Model supports native but user wants XML - defaultToolProtocol: "native", - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // User preference wins + const result = resolveToolProtocol(settings) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Native is always used }) - it("should not allow user to force native when model doesn't support it", () => { + it("should return native regardless of user preference", () => { const settings: ProviderSettings = { - toolProtocol: "native", // User wants native + toolProtocol: "native", // User preference - ignored but happens to match apiProvider: "anthropic", } - const modelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 128000, - supportsPromptCache: false, - supportsNativeTools: false, // Model doesn't support native - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Falls back to XML due to lack of support - }) - - it("should use model default when available", () => { - const settings: ProviderSettings = { - apiProvider: "roo", - } - const modelInfo: ModelInfo = { - maxTokens: 8192, - contextWindow: 200000, - supportsPromptCache: true, - defaultToolProtocol: "xml", - supportsNativeTools: true, - } - const result = resolveToolProtocol(settings, modelInfo) - expect(result).toBe(TOOL_PROTOCOL.XML) // Model default wins - }) - - it("should use native tools for OpenAI compatible provider with default model info", () => { - const settings: ProviderSettings = { - apiProvider: "openai", - } - // Using the actual openAiModelInfoSaneDefaults to verify the fix - const result = resolveToolProtocol(settings, openAiModelInfoSaneDefaults) - expect(result).toBe(TOOL_PROTOCOL.NATIVE) // Should use native tools by default + const result = resolveToolProtocol(settings) + expect(result).toBe(TOOL_PROTOCOL.NATIVE) }) }) }) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 180a51848b..8059c2ee0d 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -23,6 +23,28 @@ const OPENAI_SUPPORTED_FORMATS = new Set([ "uuid", ]) +/** + * Array-specific JSON Schema properties that must be nested inside array type variants + * when converting to anyOf format (JSON Schema draft 2020-12). + */ +const ARRAY_SPECIFIC_PROPERTIES = ["items", "minItems", "maxItems", "uniqueItems"] as const + +/** + * Applies array-specific properties from source to target object. + * Only copies properties that are defined in the source. + */ +function applyArrayProperties( + target: Record, + source: Record, +): Record { + for (const prop of ARRAY_SPECIFIC_PROPERTIES) { + if (source[prop] !== undefined) { + target[prop] = source[prop] + } + } + return target +} + /** * Zod schema for JSON Schema primitive types */ @@ -133,18 +155,42 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType }) .passthrough() .transform((schema) => { - const { type, required, properties, additionalProperties, format, ...rest } = schema + const { + type, + required, + properties, + additionalProperties, + format, + items, + minItems, + maxItems, + uniqueItems, + ...rest + } = schema const result: Record = { ...rest } // Determine if this schema represents an object type const isObjectType = type === "object" || (Array.isArray(type) && type.includes("object")) || properties !== undefined + // Collect array-specific properties for potential use in type handling + const arrayProps = { items, minItems, maxItems, uniqueItems } + // If type is an array, convert to anyOf format (JSON Schema 2020-12) + // Array-specific properties must be moved inside the array variant if (Array.isArray(type)) { - result.anyOf = type.map((t) => ({ type: t })) + result.anyOf = type.map((t) => { + if (t === "array") { + return applyArrayProperties({ type: t }, arrayProps) + } + return { type: t } + }) } else if (type !== undefined) { result.type = type + // For single "array" type, preserve array-specific properties at root + if (type === "array") { + applyArrayProperties(result, arrayProps) + } } // Strip unsupported format values for OpenAI compatibility diff --git a/src/utils/mcp-name.ts b/src/utils/mcp-name.ts index 55845d67ed..c81d5e770f 100644 --- a/src/utils/mcp-name.ts +++ b/src/utils/mcp-name.ts @@ -17,6 +17,16 @@ export const MCP_TOOL_SEPARATOR = "--" */ export const MCP_TOOL_PREFIX = "mcp" +/** + * Check if a tool name is an MCP tool (starts with the MCP prefix and separator). + * + * @param toolName - The tool name to check + * @returns true if the tool name starts with "mcp--", false otherwise + */ +export function isMcpTool(toolName: string): boolean { + return toolName.startsWith(`${MCP_TOOL_PREFIX}${MCP_TOOL_SEPARATOR}`) +} + /** * Sanitize a name to be safe for use in API function names. * This removes special characters and ensures the name starts correctly. 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/src/utils/resolveToolProtocol.ts b/src/utils/resolveToolProtocol.ts index 2f8ddea0c3..92041fbeaf 100644 --- a/src/utils/resolveToolProtocol.ts +++ b/src/utils/resolveToolProtocol.ts @@ -1,5 +1,5 @@ import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types" -import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import type { ProviderSettings } from "@roo-code/types" import type { Anthropic } from "@anthropic-ai/sdk" import { findLast, findLastIndex } from "../shared/array" @@ -12,48 +12,35 @@ type ApiMessageForDetection = Anthropic.MessageParam & { } /** - * Resolve the effective tool protocol based on the precedence hierarchy: + * Resolve the effective tool protocol. * - * 0. Locked Protocol (task-level lock, if provided - highest priority) - * 1. User Preference - Per-Profile (explicit profile setting) - * 2. Model Default (defaultToolProtocol in ModelInfo) - * 3. Native Fallback (final fallback) + * **Deprecation Note (XML Protocol):** + * XML tool protocol has been deprecated. All models now use Native tool calling. + * User/profile preferences (`providerSettings.toolProtocol`) and model defaults + * (`modelInfo.defaultToolProtocol`) are ignored. * - * Then check support: if protocol is "native" but model doesn't support it, use XML. + * Precedence: + * 1. Locked Protocol (task-level lock for resumed tasks - highest priority) + * 2. Native (always, for all new tasks) * - * @param providerSettings - The provider settings for the current profile - * @param modelInfo - Optional model information containing capabilities + * @param _providerSettings - The provider settings (toolProtocol field is ignored) + * @param _modelInfo - Unused, kept for API compatibility * @param lockedProtocol - Optional task-locked protocol that takes absolute precedence * @returns The resolved tool protocol (either "xml" or "native") */ export function resolveToolProtocol( - providerSettings: ProviderSettings, - modelInfo?: ModelInfo, + _providerSettings: ProviderSettings, + _modelInfo?: unknown, lockedProtocol?: ToolProtocol, ): ToolProtocol { - // 0. Locked Protocol - task-level lock takes absolute precedence - // This ensures tasks continue using their original protocol even if settings change + // 1. Locked Protocol - task-level lock takes absolute precedence + // This ensures resumed tasks continue using their original protocol if (lockedProtocol) { return lockedProtocol } - // If model doesn't support native tools, return XML immediately - // Treat undefined as unsupported (only allow native when explicitly true) - if (modelInfo?.supportsNativeTools !== true) { - return TOOL_PROTOCOL.XML - } - - // 1. User Preference - Per-Profile (explicit profile setting, highest priority) - if (providerSettings.toolProtocol) { - return providerSettings.toolProtocol - } - - // 2. Model Default - model's preferred protocol - if (modelInfo?.defaultToolProtocol) { - return modelInfo.defaultToolProtocol - } - - // 3. Native Fallback + // 2. Always return Native protocol for new tasks + // All models now support native tools; XML is deprecated return TOOL_PROTOCOL.NATIVE } 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 993186bbbb..7488328614 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -44,8 +44,24 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {

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

    @@ -123,4 +139,15 @@ const CareersLink = ({ children }: { children?: ReactNode }) => ( ) +const BlogLink = ({ href, children }: { href: string; children?: ReactNode }) => ( + { + e.preventDefault() + vscode.postMessage({ type: "openExternal", url: href }) + }}> + {children} + +) + export default memo(Announcement) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index e6fd26a380..ef2231b26d 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -298,6 +298,8 @@ export const ChatRowContent = ({ style={{ color: successColor, marginBottom: "-1.5px" }}>, {t("chat:taskCompleted")}, ] + case "api_req_rate_limit_wait": + return [] case "api_req_retry_delayed": return [] case "api_req_started": @@ -327,8 +329,10 @@ export const ChatRowContent = ({ getIconSpan("arrow-swap", normalColor) ) : apiRequestFailedMessage ? ( getIconSpan("error", errorColor) - ) : ( + ) : isLast ? ( + ) : ( + getIconSpan("arrow-swap", normalColor) ), apiReqCancelReason !== null && apiReqCancelReason !== undefined ? ( apiReqCancelReason === "user_cancelled" ? ( @@ -356,7 +360,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", @@ -1149,6 +1163,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": @@ -1261,6 +1304,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 ( @@ -1273,6 +1317,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..cb367716d3 100644 --- a/webview-ui/src/components/chat/FollowUpSuggest.tsx +++ b/webview-ui/src/components/chat/FollowUpSuggest.tsx @@ -135,7 +135,7 @@ export const FollowUpSuggest = ({

    )} {suggestion.mode && ( -
    +
    {suggestion.mode}
    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")} - - -
    -
    -