diff --git a/.changeset/changelog-config.js b/.changeset/changelog-config.js index 0ab9a9e48e2..00f93f281e7 100644 --- a/.changeset/changelog-config.js +++ b/.changeset/changelog-config.js @@ -1,9 +1,9 @@ const getReleaseLine = async (changeset) => { - const [firstLine] = changeset.summary + const lines = changeset.summary .split("\n") .map((l) => l.trim()) .filter(Boolean) - return `- ${firstLine}` + return lines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)).join("\n") } const getDependencyReleaseLine = async () => { diff --git a/.changeset/config.json b/.changeset/config.json index e2acc376621..310bc510947 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": ["@roo-code/cli"] } diff --git a/.changeset/v3.54.0.md b/.changeset/v3.54.0.md new file mode 100644 index 00000000000..f2817ede2ac --- /dev/null +++ b/.changeset/v3.54.0.md @@ -0,0 +1,15 @@ +--- +"roo-cline": minor +--- + +- Remove: Roo Code Cloud and eval infrastructure from the extension, CLI, workflows, and package surfaces so the release is focused on the standalone extension (PR #12328 by @mrubens) +- Remove: All telemetry collection and analytics plumbing across the extension, website, shared types, provider flows, and related tests (PR #12324 by @mrubens) +- Remove: MDM and organization membership enforcement, including host wiring, webview state, user-facing messages, and locale strings (PR #12323 by @mrubens) +- Remove: The MCP marketplace, marketplace services, webview marketplace UI, package contributions, and related localized copy (PR #12326 by @mrubens) +- Update: Extension-facing support, diagnostics, and announcement content for the final Roo Code release, including GitHub help paths and links to Roomote, ZooCode, and Cline (PR #12341 by @brunobergher) +- Add: A cleaned docs app with GitHub Pages deployment support (PR #12344 by @brunobergher) +- Fix: Configure the docs GitHub Pages base URL so deployed assets and canonical paths load correctly under the repository Pages path (PR #12370 by @mrubens) +- Update: Point docs links in the root README, localized READMEs, and web app copy to the current GitHub Pages docs URL (PR #12371 by @mrubens) +- Remove: Stale `roocode.github.io` docs references, including the old CNAME and outdated docs README and robots.txt URLs (PR #12372 by @mrubens) +- Update: The website to focus almost entirely on the Roo Code extension and remove cloud, team, enterprise, provider, pricing, Slack, and Linear product pages (PR #12180 by @brunobergher) +- Remove: Contributor, community, social channel, and tutorial references from README files, docs, website copy, issue templates, and workflows (PR #12347 by @brunobergher) diff --git a/.dockerignore b/.dockerignore index 63599788338..4579d61580e 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/CODEOWNERS b/.github/CODEOWNERS index a3daa0f144e..e2e8fa34b63 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # These owners will be the default owners for everything in the repo -* @mrubens @cte @jr +* @mrubens @cte @jr @hannesrudolph @daniel-lxs diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0351ad19301..8c7969776da 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Feature Request - url: https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests - about: Share and vote on feature requests for Roo Code - name: Leave a Review url: https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline&ssr=false#review-details about: Enjoying Roo Code? Leave a review here! diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 7e140ec08cc..489533de694 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -64,7 +64,7 @@ body: attributes: value: | --- - Optional (for contributors): You can stop here if you're just proposing the improvement. + Optional: You can stop here if you're just proposing the improvement. - type: textarea id: acceptance-criteria diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index e83e44cd66d..00000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,75 +0,0 @@ - - -### Related GitHub Issue - - - -Closes: # - -### Roo Code Task Context (Optional) - - - -### Description - - - -### Test Procedure - - - -### Pre-Submission Checklist - - - -- [ ] **Issue Linked**: This PR is linked to an approved GitHub Issue (see "Related GitHub Issue" above). -- [ ] **Scope**: My changes are focused on the linked issue (one major feature/fix per PR). -- [ ] **Self-Review**: I have performed a thorough self-review of my code. -- [ ] **Testing**: New and/or updated tests have been added to cover my changes (if applicable). -- [ ] **Documentation Impact**: I have considered if my changes require documentation updates (see "Documentation Updates" section below). -- [ ] **Contribution Guidelines**: I have read and agree to the [Contributor Guidelines](/CONTRIBUTING.md). - -### Screenshots / Videos - - - -### Documentation Updates - - - -### Additional Notes - - - -### Get in Touch - - diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 00000000000..20961a9f2d3 --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,394 @@ +name: CLI Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.0). Leave empty to use package.json version.' + required: false + type: string + dry_run: + description: 'Dry run (build and test but do not create release).' + required: false + type: boolean + default: false + +jobs: + # Build CLI for each platform. + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: darwin-arm64 + runs-on: macos-latest + - os: ubuntu-latest + platform: linux-x64 + runs-on: ubuntu-latest + - os: ubuntu-24.04-arm + platform: linux-arm64 + runs-on: ubuntu-24.04-arm + + runs-on: ${{ matrix.runs-on }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Get version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./apps/cli/package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT + echo "Using version: $VERSION" + + - name: Build extension bundle + run: pnpm bundle + + - name: Build CLI + run: pnpm --filter @roo-code/cli build + + - name: Create release tarball + id: tarball + env: + VERSION: ${{ steps.version.outputs.version }} + PLATFORM: ${{ matrix.platform }} + run: | + RELEASE_DIR="roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build. + rm -rf "$RELEASE_DIR" + rm -f "$TARBALL" + + # Create directory structure. + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files. + echo "Copying CLI files..." + cp -r apps/cli/dist/* "$RELEASE_DIR/lib/" + + # Create package.json for npm install. + echo "Creating package.json..." + node -e " + const pkg = require('./apps/cli/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: '$VERSION', + type: 'module', + dependencies: { + '@inkjs/ui': pkg.dependencies['@inkjs/ui'], + '@trpc/client': pkg.dependencies['@trpc/client'], + 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, + 'ink': pkg.dependencies.ink, + 'p-wait-for': pkg.dependencies['p-wait-for'], + 'react': pkg.dependencies.react, + 'superjson': pkg.dependencies.superjson, + 'zustand': pkg.dependencies.zustand + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle. + echo "Copying extension bundle..." + cp -r src/dist/* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory for CommonJS. + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary. + echo "Looking for ripgrep binary..." + RIPGREP_PATH=$(find node_modules -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + echo "Found ripgrep at: $RIPGREP_PATH" + 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" + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + echo "Warning: ripgrep binary not found" + fi + + # Create the wrapper script + echo "Creating wrapper script..." + printf '%s\n' '#!/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_CLI_ROOT = join(__dirname, '..');" \ + "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'));" \ + > "$RELEASE_DIR/bin/roo" + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create empty .env file. + touch "$RELEASE_DIR/.env" + + # Create tarball. + echo "Creating tarball..." + tar -czvf "$TARBALL" "$RELEASE_DIR" + + # Clean up release directory. + rm -rf "$RELEASE_DIR" + + # Create checksum. + if command -v sha256sum &> /dev/null; then + sha256sum "$TARBALL" > "${TARBALL}.sha256" + elif command -v shasum &> /dev/null; then + shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" + fi + + echo "tarball=$TARBALL" >> $GITHUB_OUTPUT + echo "Created: $TARBALL" + ls -la "$TARBALL" + + - name: Verify tarball + env: + PLATFORM: ${{ matrix.platform }} + run: | + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Create temp directory for verification. + VERIFY_DIR=$(mktemp -d) + + # Extract and verify structure. + tar -xzf "$TARBALL" -C "$VERIFY_DIR" + + echo "Verifying tarball contents..." + ls -la "$VERIFY_DIR/roo-cli-${PLATFORM}/" + + # Check required files exist. + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/bin/roo" || { echo "Missing bin/roo"; exit 1; } + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/lib/index.js" || { echo "Missing lib/index.js"; exit 1; } + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/package.json" || { echo "Missing package.json"; exit 1; } + test -d "$VERIFY_DIR/roo-cli-${PLATFORM}/extension" || { echo "Missing extension directory"; exit 1; } + + echo "Tarball verification passed!" + + # Cleanup. + rm -rf "$VERIFY_DIR" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: cli-${{ matrix.platform }} + path: | + roo-cli-${{ matrix.platform }}.tar.gz + roo-cli-${{ matrix.platform }}.tar.gz.sha256 + retention-days: 7 + + # Create GitHub release with all platform artifacts. + release: + needs: build + runs-on: ubuntu-latest + if: ${{ !inputs.dry_run }} + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./apps/cli/package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release files + run: | + mkdir -p release + find artifacts -name "*.tar.gz" -exec cp {} release/ \; + find artifacts -name "*.sha256" -exec cp {} release/ \; + ls -la release/ + + - name: Extract changelog + id: changelog + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + CHANGELOG_FILE="apps/cli/CHANGELOG.md" + + if [ -f "$CHANGELOG_FILE" ]; then + # Extract content between version headers. + CONTENT=$(awk -v version="$VERSION" ' + BEGIN { found = 0; content = ""; target = "[" version "]" } + /^## \[/ { + if (found) { exit } + if (index($0, target) > 0) { found = 1; next } + } + found { content = content $0 "\n" } + END { print content } + ' "$CHANGELOG_FILE") + + if [ -n "$CONTENT" ]; then + echo "Found changelog content" + echo "content<> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "No changelog content found for version $VERSION" + echo "content=" >> $GITHUB_OUTPUT + fi + else + echo "No changelog file found" + echo "content=" >> $GITHUB_OUTPUT + fi + + - name: Generate checksums summary + id: checksums + run: | + echo "checksums<> $GITHUB_OUTPUT + cat release/*.sha256 >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Check for existing release + id: check_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + if gh release view "$TAG" &> /dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Delete existing release + if: steps.check_release.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + echo "Deleting existing release $TAG..." + gh release delete "$TAG" --yes || true + git push origin ":refs/tags/$TAG" || true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.version.outputs.tag }} + CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} + CHECKSUMS: ${{ steps.checksums.outputs.checksums }} + run: | + NOTES_FILE=$(mktemp) + + if [ -n "$CHANGELOG_CONTENT" ]; then + echo "## What's New" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "$CHANGELOG_CONTENT" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + fi + + echo "## Installation" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "Or install a specific version:" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Requirements" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "- Node.js 20 or higher" >> "$NOTES_FILE" + echo "- macOS Apple Silicon (M1/M2/M3/M4), Linux x64, or Linux ARM64" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Usage" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "# Run a task" >> "$NOTES_FILE" + echo 'roo "What is this project?"' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "# See all options" >> "$NOTES_FILE" + echo "roo --help" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Platform Support" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "This release includes binaries for:" >> "$NOTES_FILE" + echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE" + echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE" + echo '- `roo-cli-linux-arm64.tar.gz` - Linux ARM64' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Checksums" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "$CHECKSUMS" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + + gh release create "$TAG" \ + --title "Roo Code CLI v$VERSION" \ + --notes-file "$NOTES_FILE" \ + --prerelease \ + release/* + + rm -f "$NOTES_FILE" + echo "Release created: https://github.com/${{ github.repository }}/releases/tag/$TAG" + + # Summary job for dry runs + summary: + needs: build + runs-on: ubuntu-latest + if: ${{ inputs.dry_run }} + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Show build summary + run: | + echo "## Dry Run Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The following artifacts were built:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + find artifacts -name "*.tar.gz" | while read f; do + SIZE=$(ls -lh "$f" | awk '{print $5}') + echo "- $(basename $f) ($SIZE)" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Checksums" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat artifacts/*/*.sha256 >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index f8ac0c8642b..1592b15669b 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -58,66 +58,3 @@ jobs: uses: ./.github/actions/setup-node-pnpm - name: Run unit tests run: pnpm test - - check-openrouter-api-key: - runs-on: ubuntu-latest - outputs: - exists: ${{ steps.openrouter-api-key-check.outputs.defined }} - steps: - - name: Check if OpenRouter API key exists - id: openrouter-api-key-check - shell: bash - run: | - if [ "${{ secrets.OPENROUTER_API_KEY }}" != '' ]; then - echo "defined=true" >> $GITHUB_OUTPUT; - else - echo "defined=false" >> $GITHUB_OUTPUT; - fi - - integration-test: - runs-on: ubuntu-latest - needs: [check-openrouter-api-key] - if: needs.check-openrouter-api-key.outputs.exists == 'true' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - name: Create .env.local file - working-directory: apps/vscode-e2e - run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.local - - name: Set VS Code test version - run: echo "VSCODE_VERSION=1.101.2" >> $GITHUB_ENV - - name: Cache VS Code test runtime - uses: actions/cache@v4 - with: - path: apps/vscode-e2e/.vscode-test - key: ${{ runner.os }}-vscode-test-${{ env.VSCODE_VERSION }} - - name: Pre-download VS Code test runtime with retry - working-directory: apps/vscode-e2e - run: | - for attempt in 1 2 3; do - echo "Download attempt $attempt of 3..." - node -e " - const { downloadAndUnzipVSCode } = require('@vscode/test-electron'); - downloadAndUnzipVSCode({ version: process.env.VSCODE_VERSION || '1.101.2' }) - .then(() => { - console.log('✅ VS Code test runtime downloaded successfully'); - process.exit(0); - }) - .catch(err => { - console.error('❌ Failed to download VS Code (attempt $attempt):', err); - process.exit(1); - }); - " && break || { - if [ $attempt -eq 3 ]; then - echo "All download attempts failed" - exit 1 - fi - echo "Retrying in 5 seconds..." - sleep 5 - } - done - - name: Run integration tests - working-directory: apps/vscode-e2e - run: xvfb-run -a pnpm test:ci diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 00000000000..089ed8a68ce --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,55 @@ +name: Deploy docs to GitHub Pages + +on: + push: + branches: + - main + paths: + - "apps/docs/**" + - ".github/workflows/docs-pages.yml" + - ".github/actions/setup-node-pnpm/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + workflow_dispatch: + +concurrency: + group: docs-pages + cancel-in-progress: true + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + with: + install-args: "--frozen-lockfile" + - name: Run type check + run: pnpm --filter @roo-code/docs check-types + - name: Run lint + run: pnpm --filter @roo-code/docs lint + - name: Build docs + run: pnpm --filter @roo-code/docs build + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: apps/docs/build + + deploy: + runs-on: ubuntu-latest + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml deleted file mode 100644 index b99fd7659ef..00000000000 --- a/.github/workflows/evals.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Evals - -on: - pull_request: - types: [labeled] - workflow_dispatch: - -env: - DOCKER_BUILDKIT: 1 - COMPOSE_DOCKER_CLI_BUILD: 1 - -jobs: - evals: - # Run if triggered manually or if PR has 'evals' label. - if: github.event_name == 'workflow_dispatch' || contains(github.event.label.name, 'evals') - runs-on: blacksmith-16vcpu-ubuntu-2404 - timeout-minutes: 45 - - defaults: - run: - working-directory: packages/evals - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Create environment - run: | - cat > .env.local << EOF - OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY || 'test-key-for-build' }} - EOF - - cat > .env.development << EOF - NODE_ENV=development - DATABASE_URL=postgresql://postgres:password@db:5432/evals_development - REDIS_URL=redis://redis:6379 - HOST_EXECUTION_METHOD=docker - EOF - - - name: Build image - uses: docker/build-push-action@v6 - with: - context: . - file: packages/evals/Dockerfile.runner - tags: evals-runner:latest - cache-from: type=gha - cache-to: type=gha,mode=max - push: false - load: true - - - name: Tag image - run: docker tag evals-runner:latest evals-runner - - - name: Start containers - run: | - docker compose up -d db redis - timeout 60 bash -c 'until docker compose exec -T db pg_isready -U postgres; do sleep 2; done' - timeout 60 bash -c 'until docker compose exec -T redis redis-cli ping | grep -q PONG; do sleep 2; done' - docker compose run --rm runner sh -c 'nc -z db 5432 && echo "✓ Runner -> Database connection successful"' - docker compose run --rm runner sh -c 'nc -z redis 6379 && echo "✓ Runner -> Redis connection successful"' - docker compose run --rm runner docker ps - - - name: Run database migrations - run: docker compose run --rm runner pnpm --filter @roo-code/evals db:migrate - - - name: Run evals - run: docker compose run --rm runner pnpm --filter @roo-code/evals cli --ci - - - name: Cleanup - if: always() - run: docker compose down -v --remove-orphans diff --git a/.github/workflows/marketplace-publish.yml b/.github/workflows/marketplace-publish.yml index aef91b2d323..9f797d55341 100644 --- a/.github/workflows/marketplace-publish.yml +++ b/.github/workflows/marketplace-publish.yml @@ -37,7 +37,7 @@ jobs: pnpm vsix # Save VSIX contents to a temporary file to avoid broken pipe issues. - unzip -l bin/roo-cline-${current_package_version}.vsix > /tmp/roo-code-vsix-contents.txt + unzip -l bin/datacoves-copilot-${current_package_version}.vsix > /tmp/roo-code-vsix-contents.txt # Check for required files. grep -q "extension/package.json" /tmp/roo-code-vsix-contents.txt || exit 1 @@ -63,7 +63,7 @@ jobs: OVSX_PAT: ${{ secrets.OVSX_PAT }} run: | current_package_version=$(node -p "require('./src/package.json').version") - pnpm --filter roo-cline publish:marketplace + pnpm --filter datacoves-copilot publish:marketplace echo "Successfully published version $current_package_version to VS Code Marketplace" - name: Create GitHub Release env: @@ -88,5 +88,5 @@ jobs: --title "Release v${current_package_version}" \ --notes "$changelog_content" \ --target ${{ env.GIT_REF }} \ - bin/roo-cline-${current_package_version}.vsix + bin/datacoves-copilot-${current_package_version}.vsix echo "Successfully created GitHub Release v${current_package_version}" diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml deleted file mode 100644 index 5709bdc10a0..00000000000 --- a/.github/workflows/update-contributors.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Update Contributors # Refresh contrib.rocks image cache - -on: - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - refresh-contrib-cache: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Bump cacheBust in all README files - run: | - set -euo pipefail - TS="$(date +%s)" - # Target only the root README.md and localized READMEs under locales/*/README.md - mapfile -t FILES < <(git ls-files README.md 'locales/*/README.md' || true) - - if [ "${#FILES[@]}" -eq 0 ]; then - echo "No target README files found." >&2 - exit 1 - fi - - UPDATED=0 - for f in "${FILES[@]}"; do - if grep -q 'cacheBust=' "$f"; then - # Use portable sed in GNU environment of ubuntu-latest - sed -i -E "s/cacheBust=[0-9]+/cacheBust=${TS}/g" "$f" - echo "Updated cacheBust in $f" - UPDATED=1 - else - echo "Warning: cacheBust parameter not found in $f" >&2 - fi - done - - if [ "$UPDATED" -eq 0 ]; then - echo "No files were updated. Ensure READMEs embed contrib.rocks with cacheBust param." >&2 - exit 1 - fi - - - name: Detect changes - id: changes - run: | - if git diff --quiet; then - echo "changed=false" >> $GITHUB_OUTPUT - else - echo "changed=true" >> $GITHUB_OUTPUT - fi - - - name: Create Pull Request - if: steps.changes.outputs.changed == 'true' - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: "docs: update contributors list [skip ci]" - committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" - branch: refresh-contrib-cache - delete-branch: true - title: "Refresh contrib.rocks image cache (all READMEs)" - body: | - Automated refresh of the contrib.rocks image cache by bumping the cacheBust parameter in README.md and locales/*/README.md. - base: main diff --git a/.github/workflows/website-deploy.yml b/.github/workflows/website-deploy.yml deleted file mode 100644 index 20eea4288a9..00000000000 --- a/.github/workflows/website-deploy.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy roocode.com - -on: - push: - branches: - - main - paths: - - 'apps/web-roo-code/**' - workflow_dispatch: - -env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - -jobs: - check-secrets: - runs-on: ubuntu-latest - outputs: - has-vercel-token: ${{ steps.check.outputs.has-vercel-token }} - steps: - - name: Check if VERCEL_TOKEN exists - id: check - run: | - if [ -n "${{ secrets.VERCEL_TOKEN }}" ]; then - echo "has-vercel-token=true" >> $GITHUB_OUTPUT - else - echo "has-vercel-token=false" >> $GITHUB_OUTPUT - fi - - deploy: - runs-on: ubuntu-latest - needs: check-secrets - if: ${{ needs.check-secrets.outputs.has-vercel-token == 'true' }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - name: Install Vercel CLI - run: npm install --global vercel@canary - - name: Pull Vercel Environment Information - run: npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} - - name: Build Project Artifacts - run: npx vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} - - name: Deploy Project Artifacts to Vercel - run: npx vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} diff --git a/.github/workflows/website-preview.yml b/.github/workflows/website-preview.yml deleted file mode 100644 index 6966005eafb..00000000000 --- a/.github/workflows/website-preview.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Preview roocode.com - -on: - push: - branches-ignore: - - main - paths: - - "apps/web-roo-code/**" - pull_request: - paths: - - "apps/web-roo-code/**" - workflow_dispatch: - -env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - -jobs: - check-secrets: - runs-on: ubuntu-latest - outputs: - has-vercel-token: ${{ steps.check.outputs.has-vercel-token }} - steps: - - name: Check if VERCEL_TOKEN exists - id: check - run: | - if [ -n "${{ secrets.VERCEL_TOKEN }}" ]; then - echo "has-vercel-token=true" >> $GITHUB_OUTPUT - else - echo "has-vercel-token=false" >> $GITHUB_OUTPUT - fi - - preview: - runs-on: ubuntu-latest - needs: check-secrets - if: ${{ needs.check-secrets.outputs.has-vercel-token == 'true' }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - name: Install Vercel CLI - run: npm install --global vercel@canary - - name: Pull Vercel Environment Information - run: npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} - - name: Build Project Artifacts - run: npx vercel build --token=${{ secrets.VERCEL_TOKEN }} - - name: Deploy Project Artifacts to Vercel - id: deploy - run: | - DEPLOYMENT_URL=$(npx vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}) - echo "deployment_url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT - echo "Preview deployed to: $DEPLOYMENT_URL" - - - name: Comment PR with preview link - if: github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const deploymentUrl = '${{ steps.deploy.outputs.deployment_url }}'; - const commentIdentifier = ''; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - - const existingComment = comments.find(comment => - comment.body.includes(commentIdentifier) - ); - - if (existingComment) { - return; - } - - const comment = commentIdentifier + '\n🚀 **Preview deployed!**\n\nYour changes have been deployed to Vercel:\n\n**Preview URL:** ' + deploymentUrl + '\n\nThis preview will be updated automatically when you push new commits to this PR.'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); diff --git a/.gitignore b/.gitignore index 54cf66cee7a..1dbcdc6a362 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ bin/ # Local prompts and rules /local-prompts +AGENTS.local.md # Test environment .test_env @@ -51,4 +52,6 @@ logs qdrant_storage/ # Architect plans -plans/ \ No newline at end of file +plans/ + +roo-cli-*.tar.gz* diff --git a/.roo/commands/cli-release.md b/.roo/commands/cli-release.md new file mode 100644 index 00000000000..5e68e4df2d1 --- /dev/null +++ b/.roo/commands/cli-release.md @@ -0,0 +1,86 @@ +--- +description: "Prepare a new release of the Roo Code CLI" +argument-hint: "[version-description]" +mode: code +--- + +1. Identify changes since the last CLI release: + + - Get the last CLI release tag: `gh release list --limit 10 | grep "cli-v"` + - View changes since last release: `git log cli-v..HEAD -- apps/cli --oneline` + - Or for uncommitted changes: `git diff --stat -- apps/cli` + +2. Review and summarize the changes to determine an appropriate changelog entry. Group changes by type: + + - **Added**: New features + - **Changed**: Changes to existing functionality + - **Fixed**: Bug fixes + - **Removed**: Removed features + - **Tests**: New or updated tests + +3. Bump the version in `apps/cli/package.json`: + + - Increment the patch version (e.g., 0.0.43 → 0.0.44) for bug fixes and minor changes + - Increment the minor version (e.g., 0.0.43 → 0.1.0) for new features + - Increment the major version (e.g., 0.0.43 → 1.0.0) for breaking changes + +4. Update `apps/cli/CHANGELOG.md` with a new entry: + + - Add a new section at the top (below the header) following this format: + + ```markdown + ## [X.Y.Z] - YYYY-MM-DD + + ### Added + + - Description of new features + + ### Changed + + - Description of changes + + ### Fixed + + - Description of bug fixes + ``` + + - Use the current date in YYYY-MM-DD format + - Include links to relevant source files where helpful + - Describe changes from the user's perspective + +5. Create a release branch and commit the changes: + + ```bash + # Ensure you're on main and up to date + git checkout main + git pull origin main + + # Create a new branch for the release + git checkout -b cli-release-v + + # Commit the version bump and changelog update + git add apps/cli/package.json apps/cli/CHANGELOG.md + git commit -m "chore(cli): prepare release v" + + # Push the branch to origin + git push -u origin cli-release-v + ``` + +6. Create a pull request for the release: + + ```bash + gh pr create --title "chore(cli): prepare release v" \ + --body "## CLI Release v + + This PR prepares the CLI release v. + + ### Changes + - Version bump in package.json + - Changelog update + + ### Checklist + - [ ] Version number is correct + - [ ] Changelog entry is complete and accurate + - [ ] All CI checks pass" \ + --base main + ``` diff --git a/.roo/commands/commit.md b/.roo/commands/commit.md new file mode 100644 index 00000000000..7796c49fa99 --- /dev/null +++ b/.roo/commands/commit.md @@ -0,0 +1,80 @@ +--- +description: "Commit and push changes with a descriptive message" +argument-hint: "[optional-context]" +mode: code +--- + +1. Analyze the current changes to understand what needs to be committed: + + ```bash + # Check for staged and unstaged changes + git status --short + + # View the diff of all changes (staged and unstaged) + git diff HEAD + ``` + +2. Based on the diff output, formulate a commit message following conventional commit format: + + - **feat**: New feature or functionality + - **fix**: Bug fix + - **refactor**: Code restructuring without behavior change + - **docs**: Documentation changes + - **test**: Adding or updating tests + - **chore**: Maintenance tasks, dependencies, configs + - **style**: Formatting, whitespace, no logic changes + + Format: `type(scope): brief description` + + Examples: + + - `feat(api): add user authentication endpoint` + - `fix(ui): resolve button alignment on mobile` + - `refactor(core): simplify error handling logic` + - `docs(readme): update installation instructions` + +3. Stage all unstaged changes: + + ```bash + git add -A + ``` + +4. Commit with the generated message: + + ```bash + git commit -m "type(scope): brief description" + ``` + + **If pre-commit hooks fail:** + + - Review the error output (linter errors, type checking errors, etc.) + - Fix the identified issues in the affected files + - Re-stage the fixes: `git add -A` + - Retry the commit: `git commit -m "type(scope): brief description"` + +5. Push to the remote repository: + + ```bash + git push + ``` + + **If pre-push hooks fail:** + + - Review the error output (test failures, linter errors, etc.) + - Fix the identified issues in the affected files + - Stage and commit the fixes using steps 3-4 + - Retry the push: `git push` + +**Tips for good commit messages:** + +- Keep the first line under 72 characters +- Use imperative mood ("add", "fix", "update", not "added", "fixes", "updated") +- Be specific but concise +- If multiple unrelated changes exist, consider splitting into separate commits + +**Common hook failures and fixes:** + +- **Linter errors**: Run the project's linter (e.g., `npm run lint` or `pnpm lint`) to see all issues, then fix them +- **Type checking errors**: Run type checker (e.g., `npx tsc --noEmit`) to identify type issues +- **Test failures**: Run tests (e.g., `npm test` or `pnpm test`) to identify failing tests and fix them +- **Format issues**: Run formatter (e.g., `npm run format` or `pnpm format`) to auto-fix formatting diff --git a/.roo/commands/roo-resolve-conflicts.md b/.roo/commands/roo-resolve-conflicts.md new file mode 100644 index 00000000000..38b2038658c --- /dev/null +++ b/.roo/commands/roo-resolve-conflicts.md @@ -0,0 +1,74 @@ +--- +description: "Resolve merge conflicts intelligently using git history analysis" +argument-hint: "#PR-number" +mode: merge-resolver +--- + +Resolve merge conflicts for a specific pull request by analyzing git history, commit messages, and code changes to make intelligent resolution decisions. + +## Quick Start + +1. **Provide a PR number** (e.g., `#123` or just `123`) + +2. The workflow will automatically: + - Fetch PR information (title, description, branches) + - Checkout the PR branch + - Rebase onto the target branch to reveal conflicts + - Analyze and resolve conflicts using git history + +## Workflow Steps + +### 1. Initialize PR Resolution + +```bash +# Fetch PR info +gh pr view [PR_NUMBER] --json title,body,headRefName,baseRefName + +# Checkout and rebase +gh pr checkout [PR_NUMBER] --force +git fetch origin main +GIT_EDITOR=true git rebase origin/main +``` + +### 2. Identify Conflicts + +```bash +git status --porcelain | grep "^UU" +``` + +### 3. Analyze Each Conflict + +For each conflicted file: + +- Read the conflict markers +- Run `git blame` on conflicting sections +- Fetch commit messages for context +- Determine the intent behind each change + +### 4. Apply Resolution Strategy + +Based on the analysis: + +- **Bugfixes** generally take precedence over features +- **Recent changes** are often more relevant (unless older is a security fix) +- **Combine** non-conflicting changes when possible +- **Preserve** test updates alongside code changes + +### 5. Complete Resolution + +```bash +git add [resolved-files] +GIT_EDITOR=true git rebase --continue +``` + +## Key Guidelines + +- Always escape conflict markers with `\` when using `apply_diff` +- Document resolution decisions in the summary +- Verify no syntax errors after resolution +- Preserve valuable changes from both sides when possible + +## Examples + +- `/roo-resolve-conflicts #123` - Resolve conflicts for PR #123 +- `/roo-resolve-conflicts 456` - Resolve conflicts for PR #456 diff --git a/.roo/commands/roo-translate.md b/.roo/commands/roo-translate.md new file mode 100644 index 00000000000..28a8dc67c84 --- /dev/null +++ b/.roo/commands/roo-translate.md @@ -0,0 +1,53 @@ +--- +description: "Translate and localize strings in the Roo Code extension" +argument-hint: "[language-code or 'all'] [string-key or file-path]" +mode: translate +--- + +Perform translation and localization tasks for the Roo Code extension. This command activates the translation workflow with comprehensive i18n guidelines. + +## Quick Start + +1. **Identify the translation scope:** + + - If a specific language code is provided (e.g., `de`, `zh-CN`), focus on that language + - If `all` is specified, translate to all supported languages + - If a string key is provided, locate and translate that specific string + - If a file path is provided, work with that translation file + +2. **Supported languages:** ca, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW + +3. **Translation locations:** + - Core Extension: `src/i18n/locales/` + - WebView UI: `webview-ui/src/i18n/locales/` + +## Workflow + +1. If adding new strings: + + - Add the English string first + - Ask for confirmation before translating to other languages + - Use `apply_diff` for efficient file updates + +2. If updating existing strings: + + - Identify all affected language files + - Update English first, then propagate changes + +3. Validate your changes: + ```bash + node scripts/find-missing-translations.js + ``` + +## Key Guidelines + +- Use informal speech (e.g., "du" not "Sie" in German) +- Keep technical terms like "token", "Prompt" in English +- Preserve all `{{variable}}` placeholders exactly +- Use `apply_diff` instead of `write_to_file` for existing files + +## Examples + +- `/roo-translate de` - Focus on German translations +- `/roo-translate all welcome.title` - Translate a specific key to all languages +- `/roo-translate zh-CN src/i18n/locales/zh-CN/core.json` - Work on specific file diff --git a/.roo/guidance/roo-translator.md b/.roo/guidance/roo-translator.md new file mode 100644 index 00000000000..2539778f27f --- /dev/null +++ b/.roo/guidance/roo-translator.md @@ -0,0 +1,15 @@ +# Roo Code Translation Guidance + +This file contains brand voice, tone, and word choice guidelines for Roo Code translations. + +## Brand Voice + + + +## Tone + + + +## Word Choice + + diff --git a/.roo/rules-debug/cli.md b/.roo/rules-debug/cli.md new file mode 100644 index 00000000000..7992718ffad --- /dev/null +++ b/.roo/rules-debug/cli.md @@ -0,0 +1,67 @@ +# CLI Debugging with File-Based Logging + +When debugging the CLI, `console.log` will break the TUI (Terminal User Interface). Use file-based logging to capture debug output without interfering with the application's display. + +## File-Based Logging Strategy + +1. **Write logs to a temporary file instead of console**: + + - Create a log file at a known location, e.g., `/tmp/roo-cli-debug.log` + - Use `fs.appendFileSync()` to write timestamped log entries + - Example logging utility: + + ```typescript + import fs from "fs" + const DEBUG_LOG = "/tmp/roo-cli-debug.log" + + function debugLog(message: string, data?: unknown) { + const timestamp = new Date().toISOString() + const entry = data + ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n` + : `[${timestamp}] ${message}\n` + fs.appendFileSync(DEBUG_LOG, entry) + } + ``` + +2. **Clear the log file before each debugging session**: + - Run `echo "" > /tmp/roo-cli-debug.log` or use `fs.writeFileSync(DEBUG_LOG, "")` at app startup during debugging + +## Iterative Debugging Workflow + +Follow this feedback loop to systematically narrow down issues: + +1. **Add targeted logging** at suspected problem areas based on your hypotheses +2. **Instruct the user** to reproduce the issue using the CLI normally +3. **Read the log file** after the user completes testing: + - Run `cat /tmp/roo-cli-debug.log` to retrieve the captured output +4. **Analyze the log output** to gather clues about: + - Execution flow and timing + - Variable values at key points + - Which code paths were taken + - Error conditions or unexpected states +5. **Refine your logging** based on findings—add more detail where needed, remove noise +6. **Ask the user to test again** with updated logging +7. **Repeat** until the root cause is identified + +## Best Practices + +- Log entry/exit points of functions under investigation +- Include relevant variable values and state information +- Use descriptive prefixes to categorize logs: `[STATE]`, `[EVENT]`, `[ERROR]`, `[FLOW]` +- Log both the "happy path" and error handling branches +- When dealing with async operations, log before and after `await` statements +- For user interactions, log the received input and the resulting action + +## Example Debug Session + +```typescript +// Add logging to investigate a picker selection issue +debugLog("[FLOW] PickerSelect onSelect called", { selectedIndex, item }) +debugLog("[STATE] Current selection state", { currentValue, isOpen }) + +// After async operation +const result = await fetchOptions() +debugLog("[FLOW] fetchOptions completed", { resultCount: result.length }) +``` + +Then ask: "Please reproduce the issue by [specific steps]. When you're done, let me know and I'll analyze the debug logs." diff --git a/.roo/rules-docs-extractor/1_extraction_workflow.xml b/.roo/rules-docs-extractor/1_extraction_workflow.xml index c707fa78092..200e48da0c7 100644 --- a/.roo/rules-docs-extractor/1_extraction_workflow.xml +++ b/.roo/rules-docs-extractor/1_extraction_workflow.xml @@ -1,163 +1,113 @@ - - The Docs Extractor mode has exactly two workflow paths: - 1) Verify provided documentation for factual accuracy against the codebase - 2) Generate source material for user-facing docs about a requested feature or aspect of the codebase + + Extract raw facts from a codebase about a feature or aspect. + Output is structured data for documentation teams to use. + Do NOT write documentation. Do NOT format prose. Do NOT make structure decisions. + - Outputs are designed to support explanatory documentation (not merely descriptive): - - Capture why users need steps and why certain actions are restricted - - Surface constraints, limitations, and trade‑offs - - Provide troubleshooting playbooks (symptoms → causes → fixes → prevention) - - Recommend targeted visuals for complex states (not step‑by‑step screenshots) - - This mode does not generate final user documentation; it produces verification and source-material reports for docs teams. - - - + - Parse Request + Identify Target - Identify the feature/aspect in the user's request. - Decide path: verification vs. source-material generation. - For source-material: capture audience (user or developer) and depth (overview vs task-focused). - For verification: identify the documentation to be verified (provided text/links/files). - Note any specific areas to emphasize or check. + Parse the user's request to identify the feature/aspect + Clarify scope if ambiguous (ask one question max) - Discover Feature + Discover Code + + Use codebase_search to find relevant files + Identify entry points, components, and related code + Map the boundaries of the feature + + + + + Extract Facts + + Read code and extract facts into categories (see fact_categories) + Record file paths as sources for each fact + Do NOT interpret, summarize, or explain - just extract + + + + + Output Structured Data - Locate relevant code and assets using appropriate discovery methods. - Identify entry points and key components that affect user experience. - Map the high-level workflow a user follows. + Write extraction to .roo/extraction/EXTRACT-[feature].yaml + Use the output schema (see output_format.xml) - + + + + + + Feature name as it appears in code + File paths where feature is implemented + Entry points (commands, UI elements, API endpoints) + + + + + + What the feature does (from code logic) + Inputs it accepts + Outputs it produces + Side effects (files created, state changed, etc.) + + + + + + Settings/options that affect behavior + Default values + Valid ranges or allowed values + Where configured (settings file, env var, UI) + + + + + + Prerequisites and dependencies + Limitations (what it cannot do) + Permissions required + Compatibility requirements + + - - UI components and their interactions - User workflows and decision points - Configuration that changes user-visible behavior - Error states, messages, and recovery - Benefits, limits, prerequisites, and version notes - Why this exists: user goals, constraints, and design intent - “Cannot do” boundaries: permissions, invariants, and business rules - Troubleshooting: symptoms, likely causes, diagnostics, fixes, prevention - Common pitfalls and anti‑patterns (what to avoid and why) - Decision rationale and trade‑offs that affect user choices - Complex UI states that merit visuals (criteria for screenshots/diagrams) - + + + Error conditions in code + Error messages (exact text) + Recovery paths in code + + - - - Generate Source Material for User-Facing Docs - Extract concise, user-oriented facts and structure them for documentation teams. - - - Scope and Audience - - Confirm the feature/aspect and intended audience. - List primary tasks the audience performs with this feature. - - - - Extract User-Facing Facts - - Summarize what the feature does and key benefits. - Explain why users need this (jobs-to-be-done, outcomes) and when to use it. - Document step-by-step user workflows and UI interactions. - Capture configuration options that impact user behavior (name, default, effect). - Clarify constraints, limits, and “cannot do” cases with rationale. - Identify common pitfalls and anti-patterns; include “Do/Don’t” guidance. - List common errors with user-facing messages, diagnostics, fixes, and prevention. - Record prerequisites, permissions, and compatibility/version notes. - Flag complex states that warrant visuals (what to show and why), not every step. - - - - Create Source Material Report - - Organize findings using user-focused structure (benefits, use cases, how it works, configuration, FAQ, troubleshooting). - Include short code/UI snippets or paths where relevant. - Create `EXTRACTION-[feature].md` with findings. - Highlight items that need visuals (screenshots/diagrams). - - - - Executive summary of the feature/aspect - - Why it matters (goals, value, when to use) - - User workflows and interactions - - Configuration and setup affecting users (with defaults and impact) - - Constraints and limitations (with rationale) - - Common scenarios and troubleshooting playbooks (symptoms → causes → fixes → prevention) - - Do/Don’t and anti‑patterns - - Recommended visuals (what complex states to illustrate and why) - - FAQ and tips - - Version/compatibility notes - - - - + + + UI components involved + User-visible labels and text + Interaction patterns + + - - Verify Documentation Accuracy - Check provided documentation against codebase reality and actual UX. - - - Analyze Provided Documentation - - Parse the documentation to identify claims and descriptions. - Extract technical or user-facing specifics mentioned. - Note workflows, configuration, and examples described. - - - - Verify Against Codebase - - Check claims against actual implementation and UX. - Verify endpoints/parameters if referenced. - Confirm configuration options and defaults. - Validate code snippets and examples. - Ensure described workflows match implementation. - - - - Create Verification Report - - Categorize findings by severity (Critical, Major, Minor). - List inaccuracies with the correct information. - Identify missing important information. - Provide specific corrections and suggestions. - Create `VERIFICATION-[feature].md` with findings. - - - - Verification summary (Accurate/Needs Updates) - - Critical inaccuracies that could mislead users - - Corrections and missing information - - Explanatory gaps (missing “why”, constraints, or decision rationale) - - Troubleshooting coverage gaps (missing symptoms/diagnostics/fixes/prevention) - - Visual recommendations (which complex states warrant screenshots/diagrams) - - Suggestions for clarity improvements - - - - - + + + Other features this interacts with + External APIs or services called + Events emitted or consumed + + + - - - Audience and scope captured - User workflows and UI interactions documented - User-impacting configuration recorded - Common errors and troubleshooting documented - Report organized for documentation team use - - - All documentation claims verified - Inaccuracies identified and corrected - Missing information noted - Suggestions for improvement provided - Clear verification report created - - + + Extract facts, not opinions + Include source file paths for every fact + Use code identifiers and exact strings from source + Do NOT paraphrase - quote when possible + Do NOT decide what's important - extract everything relevant + Do NOT format for end users - output is for docs team + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/2_documentation_patterns.xml b/.roo/rules-docs-extractor/2_documentation_patterns.xml deleted file mode 100644 index da743483dab..00000000000 --- a/.roo/rules-docs-extractor/2_documentation_patterns.xml +++ /dev/null @@ -1,357 +0,0 @@ - - - Standard templates for structuring extracted documentation. - - - - -# [Feature Name] - -[Description of what the feature does and why a user should care.] - -### Key Features -- [Benefit-oriented feature 1] -- [Benefit-oriented feature 2] -- [Benefit-oriented feature 3] - ---- - -## Use Case - -**Before**: [Description of the old way] -- [Pain point 1] -- [Pain point 2] - -**With this feature**: [Description of the new experience.] - -## How it Works - -[Simple explanation of the feature's operation.] - -[Suggest visual representations where helpful.] - ---- - -## Configuration - -[Explanation of relevant settings.] - -1. **[Setting Name]**: - - **Setting**: `[technical_name]` - - **Description**: [What this does.] - - **Default**: [Default value and its meaning.] - -2. **[Setting Name]**: - - **Setting**: `[technical_name]` - - **Description**: [What this does.] - - **Default**: [Default value and its meaning.] - ---- - -## FAQ - -**"[User question]"** -- [Answer.] -- [Optional tip.] - -**"[User question]"** -- [Answer.] -- [Optional tip.] - - - - -# [Feature Name] Technical Documentation - -## Table of Contents -1. Overview -2. Quick Start -3. Architecture -4. API Reference -5. Configuration -6. User Guide -7. Developer Guide -8. Security -9. Performance -10. Troubleshooting -11. FAQ -12. Changelog -13. References - -[Use this as an internal source-material outline for technical sections; not for final docs.] - - - - - - - - - - --- - Separate sections. - - - - - - - - Show tool output or UI elements. - Use actual file paths and setting names. - Include common errors and solutions. - - - - - - - - - - - - - - - Tutorials - Use cases - Troubleshooting - Benefits - - - - - - - Code examples - API specs - Integration patterns - Performance - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - [Link Text](#section-anchor) - [See Configuration Guide](#configuration) - - - - [Link Text](https://external.url) - [Official Documentation](https://docs.example.com) - - - - - - - - - - - \ No newline at end of file diff --git a/.roo/rules-docs-extractor/2_verification_workflow.xml b/.roo/rules-docs-extractor/2_verification_workflow.xml new file mode 100644 index 00000000000..4635d8eb45b --- /dev/null +++ b/.roo/rules-docs-extractor/2_verification_workflow.xml @@ -0,0 +1,85 @@ + + + Compare provided documentation against actual codebase implementation. + Output is a structured diff of claims vs reality. + Do NOT rewrite the docs. Do NOT suggest wording. Just report discrepancies. + + + + + Receive Documentation + + User provides documentation to verify (text, file, or URL) + Identify the feature/aspect being documented + + + + + Extract Claims + + Parse the documentation into discrete claims + Tag each claim with a category (behavior, config, constraint, etc.) + Record the exact quote from the documentation + + + + + Verify Against Code + + For each claim, find the relevant code + Compare claim to actual implementation + Record: ACCURATE, INACCURATE, OUTDATED, MISSING_CONTEXT, or UNVERIFIABLE + For inaccuracies, record what the code actually does + + + + + Output Verification Report + + Write verification to .roo/extraction/VERIFY-[feature].yaml + Use the output schema (see output_format.xml) + + + + + + + Claim matches implementation + + + Claim contradicts implementation + What the code actually does + + + Claim was once true but code has changed + Current behavior + + + Claim is true but omits important information + The missing context + + + Cannot find code to verify this claim + Search paths attempted + + + + + behavior + configuration + constraint + error_handling + ui + integration + prerequisite + + + + Verify facts, not writing quality + Report what code does, not what docs should say + Include source file paths as evidence + Do NOT suggest documentation rewrites + Do NOT evaluate if docs are "good" - only if they're accurate + Quote exact code when showing discrepancies + + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/3_analysis_techniques.xml b/.roo/rules-docs-extractor/3_analysis_techniques.xml deleted file mode 100644 index 12b3d1fd266..00000000000 --- a/.roo/rules-docs-extractor/3_analysis_techniques.xml +++ /dev/null @@ -1,349 +0,0 @@ - - - Heuristics for analyzing a codebase to extract reliable, user-facing documentation. - This file contains technique checklists only—no tool instructions or invocations. - - - - - Find and analyze UI components and their interactions - - Start from feature or route directories and enumerate components related to the requested topic. - Differentiate container vs presentational components; note composition patterns. - Trace inputs/outputs: props, state, context, events, and side effects. - Record conditional rendering that affects user-visible states. - - - Primary components and responsibilities. - Props/state/context that change behavior. - High-level dependency/composition map. - - - - - Analyze styling and visual elements - - Identify design tokens and utility classes used to drive layout and state. - Capture responsive behavior and breakpoint rules that materially change UX. - Document visual affordances tied to state (loading, error, disabled). - - - Key classes/selectors influencing layout/state. - Responsive behavior summary and breakpoints. - - - - - Map user interactions and navigation flows - - Route definitions and navigation - Form submissions and validations - Button clicks and event handlers - State changes and UI updates - Loading and error states - - - Outline entry points and expected outcomes for each primary flow. - Summarize validation rules and failure states the user can encounter. - Record redirects and deep-link behavior relevant to the feature. - - - Flow diagrams or bullet sequences for main tasks. - Validation conditions and error messages. - Navigation transitions and guards. - - - - - Analyze how the system communicates with users - - Error messages and alerts - Success notifications - Loading indicators - Tooltips and help text - Confirmation dialogs - Progress indicators - - - Map message triggers to the user actions that cause them. - Capture severity, persistence, and dismissal behavior. - Note localization or accessibility considerations in messages. - - - Catalog of messages with purpose and conditions. - Loading/progress patterns and timeouts. - - - - - Check for accessibility features and compliance - - ARIA labels and roles - Keyboard navigation support - Screen reader compatibility - Focus management - Color contrast considerations - - - Confirm interactive elements have clear focus and labels. - Describe keyboard-only navigation paths for core flows. - - - Accessibility gaps affecting task completion. - - - - - Analyze responsive design and mobile experience - - Breakpoint definitions - Mobile-specific components - Touch event handlers - Viewport configurations - Media queries - - - Summarize layout changes across breakpoints that alter workflow. - Note touch targets and gestures required on mobile. - - - Table of key differences per breakpoint. - - - - - - - Understand feature entry points and control flow - - Identify main functions, controllers, or route handlers. - Trace execution and decision branches. - Document input validation and preconditions. - - - Entry points list and short purpose statements. - Decision matrix or flow sketch. - - - - - Extract API specifications from code - - - - HTTP method and route path - Path/query parameters - Request/response schemas - Status codes and error bodies - - - - - Schema and input types - Resolvers and return types - Field arguments and constraints - - - - - - - Map dependencies and integration points - - Imports and module boundaries - Package and runtime dependencies - External API/SDK usage - DB connections and migrations - Messaging/queue/event streams - Filesystem or network side effects - - - Dependency graph summary and hot spots. - List of external integrations and auth methods. - - - - - Extract data models, schemas, and type definitions - - - - interfaces, types, classes, enums - - - - Schema definitions, migration files, ORM models - - - - JSON Schema, Joi/Yup/Zod schemas, validation decorators - - - - Canonical definitions and field constraints. - Entity relationships and ownership. - - - - - Identify and document business rules - - Complex conditionals - Calculation functions - Validation rules - State machines - Domain-specific constants and algorithms - - - Why the logic exists (business need) - When the logic applies (conditions) - What the logic does (transformation) - Edge cases and invariants - Impact of changes - - - - - Document error handling and recovery - - try/catch blocks and error boundaries - Custom error classes and codes - Logging, fallbacks, retries, circuit breakers - - - Error taxonomy and user-facing messages. - Recovery/rollback strategies and timeouts. - - - - - Identify security measures and vulnerabilities - - JWT, sessions, OAuth, API keys - RBAC, permission checks, ownership validation - Encryption, hashing, sensitive data handling - Sanitization and injection prevention - - - Threat surfaces and mitigations relevant to the feature. - - - - - Identify performance factors and optimization opportunities - - Expensive loops/algorithms - DB query patterns (e.g., N+1) - Caching strategies - Concurrency and async usage - Batching and resource pooling - Memory management and object lifetimes - - - Time/space complexity - DB query counts - API response times - Memory usage - Concurrency handling - - - - - Assess test coverage at a useful granularity - - - Function-level coverage and edge cases - - - Workflow coverage and contract boundaries - - - Endpoint success/failure paths and schemas - - - - List of critical behaviors missing tests. - - - - - Extract configuration options and their impacts - - .env files, config files, CLI args, feature flags - - - Default values and valid ranges - Behavioral impact of each option - Dependencies between options - Security implications - - - - - - - Map user workflows through the feature - - Identify entry points (UI, API, CLI) - Trace user actions and decision points - Map data transformations - Identify outcomes and completion criteria - - - Flow diagrams, procedures, decision trees, state diagrams - - - - - Document integration with other systems - - Sync API calls, async messaging, events, batch processing, streaming - - - Protocols, auth, error handling, data transforms, SLAs - - - - - - - Summarize version constraints and compatibility - - package manifests, READMEs, migration guides, breaking changes docs - - - Minimum/recommended versions and notable constraints. - - - - - Track deprecations and migrations - - Explicit deprecation notices and TODO markers - Legacy code paths and adapters - - - Deprecation date and removal timeline - Migration path and alternatives - - - - - - - - Public APIs documented with inputs/outputs and errors - Examples for complex features - Error scenarios covered with recovery guidance - Config options explained with defaults and impacts - Security considerations addressed - - - - - Cyclomatic complexity - Code duplication - Test coverage and gaps - Documentation coverage for user-visible behaviors - Known technical debt affecting UX - - - - \ No newline at end of file diff --git a/.roo/rules-docs-extractor/3_output_format.xml b/.roo/rules-docs-extractor/3_output_format.xml new file mode 100644 index 00000000000..185f7b23b80 --- /dev/null +++ b/.roo/rules-docs-extractor/3_output_format.xml @@ -0,0 +1,133 @@ + + + Structured data output formats for extraction and verification. + All output is YAML. No prose. No markdown formatting. + This data feeds into documentation-writer mode. + + + + Schema for EXTRACT-[feature].yaml files + + + + + Schema for VERIFY-[feature].yaml files + + + + + Use YAML, not JSON or markdown + Include source file:line for every fact + Quote exact strings from code using double quotes + Use null for unknown/missing values, not empty strings + Keep descriptions factual and brief - one line max + Do NOT add commentary, suggestions, or explanations + + + + EXTRACT-[feature-slug].yaml + VERIFY-[feature-slug].yaml + .roo/extraction/ + + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/4_communication_guidelines.xml b/.roo/rules-docs-extractor/4_communication_guidelines.xml deleted file mode 100644 index 43ec8479fc6..00000000000 --- a/.roo/rules-docs-extractor/4_communication_guidelines.xml +++ /dev/null @@ -1,298 +0,0 @@ - - - Guidelines for user communication and output formatting. - - - - - Act on the user's request immediately. - Only ask for clarification if the request is ambiguous. - - - - - Multiple features with similar names are found. - The request is ambiguous. - The user explicitly asks for options. - - - - - - - Starting a major analysis phase. - Extraction is complete. - Unexpected complexity is found. - - - - - - - - - - - Alert user to security concerns found during analysis. - - - Note deprecated features needing migration docs. - - - Highlight code that lacks inline documentation. - - - Warn about complex dependency chains. - - - - - - - - - - - - - - - Use # for main title, ## for major sections, ### for subsections. - Never skip heading levels. - - - - Always specify language for syntax highlighting (e.g., typescript, json, bash). - Include file paths as comments where relevant. - -```typescript -// src/auth/auth.service.ts -export class AuthService { - async validateUser(email: string, password: string): Promise { - // Implementation - } -} -``` - - - - - Use tables for structured data like configs. - Include headers and align columns. - Keep cell content brief. - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `JWT_SECRET` | string | - | Secret key for JWT signing | -| `JWT_EXPIRATION` | string | '15m' | Token expiration time | - - - - - Use bullets for unordered lists, numbers for sequential steps. - Keep list items parallel in structure. - - - - - - [Link text](#section-anchor) - Use lowercase, hyphenated anchors. Test all links. - - - - [Link text](https://example.com) - Use HTTPS. Link to official docs. - - - - `path/to/file.ts` - Use relative paths from project root, in backticks. - - - - - - - > ⚠️ **Warning**: [message] - Security, breaking changes, deprecations. - - - > 📝 **Note**: [message] - Important info, clarifications. - - - > 💡 **Tip**: [message] - Best practices, optimizations. - - - - - ---- -Feature: Authentication System -Version: 2.1.0 -Last Updated: 2024-01-15 -Status: Stable ---- - - - - - - - - Be direct, not conversational. - Use active voice. - Lead with benefits. - Use concrete examples. - Keep paragraphs short. - Avoid unnecessary technical details. - - - - - Technical and direct. - Standard programming terms. - Code snippets, implementation details. - - - Instructional, step-by-step. - Simple language, no jargon. - Screenshots, real-world scenarios. - - - - - - - Summary of analysis performed. - Key findings or issues identified. - Report file location. - Recommended next steps. - - - -Feature extraction complete for the authentication system. - -**Extraction Report**: `EXTRACTION-authentication-system.md` - -**Technical Summary**: -- JWT-based authentication with refresh tokens -- 5 API endpoints (login, logout, refresh, register, profile) -- 12 configuration options -- bcrypt password hashing, rate limiting - -**Non-Technical Summary**: -- Users can register, login, and manage sessions -- Supports "remember me" functionality -- Automatic session refresh for seamless experience -- Account lockout after failed attempts - -**Documentation Considerations**: -- Token expiration times need clear explanation -- Password requirements should be prominently displayed -- Error messages need user-friendly translations - -The extraction report contains all details needed for comprehensive documentation. - - - -Documentation verification complete for the authentication system. - -**Verification Report**: `VERIFICATION-authentication-system.md` - -**Overall Assessment**: Needs Updates - -**Critical Issues Found**: -1. JWT_SECRET documented as optional, but it's required -2. Token expiration listed as 30m, actual is 15m -3. Missing documentation for rate limiting feature - -**Technical Corrections**: 7 items -**Missing Information**: 4 sections -**Clarity Improvements**: 3 suggestions - -Please review the verification report for specific corrections needed. - - - - - - - - Could not find a feature matching "[feature name]". Similar features found: - - [List similar features] - Document one of these instead? - - - - - - Code for [feature] has limited inline documentation. Extracting from code structure, tests, and usage patterns. - - - - - - This feature is complex. Choose documentation scope: - - Document comprehensively - - Focus on core functionality - - Split into multiple documents - - - - - - - - No placeholder content remains. - Code examples are correct. - Links and cross-references work. - Tables are formatted correctly. - Version info is included. - Filename follows conventions. - - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/1_workflow.xml b/.roo/rules-integration-tester/1_workflow.xml deleted file mode 100644 index b0ebc535e2b..00000000000 --- a/.roo/rules-integration-tester/1_workflow.xml +++ /dev/null @@ -1,198 +0,0 @@ - - - Understand Test Requirements - - Use ask_followup_question to determine what type of integration test is needed: - - - What type of integration test would you like me to create or work on? - - New E2E test for a specific feature or workflow - Fix or update an existing integration test - Create test utilities or helpers for common patterns - Debug failing integration tests - - - - - - - Gather Test Specifications - - Based on the test type, gather detailed requirements: - - For New E2E Tests: - - What specific user workflow or feature needs testing? - - What are the expected inputs and outputs? - - What edge cases or error scenarios should be covered? - - Are there specific API interactions to validate? - - What events should be monitored during the test? - - For Existing Test Issues: - - Which test file is failing or needs updates? - - What specific error messages or failures are occurring? - - What changes in the codebase might have affected the test? - - For Test Utilities: - - What common patterns are being repeated across tests? - - What helper functions would improve test maintainability? - - Use multiple ask_followup_question calls if needed to gather complete information. - - - - - Explore Existing Test Patterns - - Use codebase_search FIRST to understand existing test patterns and similar functionality: - - For New Tests: - - Search for similar test scenarios in apps/vscode-e2e/src/suite/ - - Find existing test utilities and helpers - - Identify patterns for the type of functionality being tested - - For Test Fixes: - - Search for the failing test file and related code - - Find similar working tests for comparison - - Look for recent changes that might have broken the test - - Example searches: - - "file creation test mocha" for file operation tests - - "task completion waitUntilCompleted" for task monitoring patterns - - "api message validation" for API interaction tests - - After codebase_search, use: - - read_file on relevant test files to understand structure - - list_code_definition_names on test directories - - search_files for specific test patterns or utilities - - - - - Analyze Test Environment and Setup - - Examine the test environment configuration: - - 1. Read the test runner configuration: - - apps/vscode-e2e/package.json for test scripts - - apps/vscode-e2e/src/runTest.ts for test setup - - Any test configuration files - - 2. Understand the test workspace setup: - - How test workspaces are created - - What files are available during tests - - How the extension API is accessed - - 3. Review existing test utilities: - - Helper functions for common operations - - Event listening patterns - - Assertion utilities - - Cleanup procedures - - Document findings including: - - Test environment structure - - Available utilities and helpers - - Common patterns and best practices - - - - - Design Test Structure - - Plan the test implementation based on gathered information: - - For New Tests: - - Define test suite structure with suite/test blocks - - Plan setup and teardown procedures - - Identify required test data and fixtures - - Design event listeners and validation points - - Plan for both success and failure scenarios - - For Test Fixes: - - Identify the root cause of the failure - - Plan the minimal changes needed to fix the issue - - Consider if the test needs to be updated due to code changes - - Plan for improved error handling or debugging - - Create a detailed test plan including: - - Test file structure and organization - - Required setup and cleanup - - Specific assertions and validations - - Error handling and edge cases - - - - - Implement Test Code - - Implement the test following established patterns: - - CRITICAL: Never write a test file with a single write_to_file call. - Always implement tests in parts: - - 1. Start with the basic test structure (suite, setup, teardown) - 2. Add individual test cases one by one - 3. Implement helper functions separately - 4. Add event listeners and validation logic incrementally - - Follow these implementation guidelines: - - Use suite() and test() blocks following Mocha TDD style - - Always use the global api object for extension interactions - - Implement proper async/await patterns with waitFor utility - - Use waitUntilCompleted and waitUntilAborted helpers for task monitoring - - Listen to and validate appropriate events (message, taskCompleted, etc.) - - Test both positive flows and error scenarios - - Validate message content using proper type assertions - - Create reusable test utilities when patterns emerge - - Use meaningful test descriptions that explain the scenario - - Always clean up tasks with cancelCurrentTask or clearCurrentTask - - Ensure tests are independent and can run in any order - - - - - Run and Validate Tests - - Execute the tests to ensure they work correctly: - - ALWAYS use the correct working directory and commands: - - Working directory: apps/vscode-e2e - - Test command: npm run test:run - - For specific tests: TEST_FILE="filename.test" npm run test:run - - Example: cd apps/vscode-e2e && TEST_FILE="apply-diff.test" npm run test:run - - Test execution process: - 1. Run the specific test file first - 2. Check for any failures or errors - 3. Analyze test output and logs - 4. Debug any issues found - 5. Re-run tests after fixes - - If tests fail: - - Add console.log statements to track execution flow - - Log important events like task IDs, file paths, and AI responses - - Check test output carefully for error messages and stack traces - - Verify file creation in correct workspace directories - - Ensure proper event handling and timeouts - - - - - Document and Complete - - Finalize the test implementation: - - 1. Add comprehensive comments explaining complex test logic - 2. Document any new test utilities or patterns created - 3. Ensure test descriptions clearly explain what is being tested - 4. Verify all cleanup procedures are in place - 5. Confirm tests can run independently and in any order - - Provide the user with: - - Summary of tests created or fixed - - Instructions for running the tests - - Any new patterns or utilities that can be reused - - Recommendations for future test improvements - - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/2_test_patterns.xml b/.roo/rules-integration-tester/2_test_patterns.xml deleted file mode 100644 index 62bef1631bb..00000000000 --- a/.roo/rules-integration-tester/2_test_patterns.xml +++ /dev/null @@ -1,303 +0,0 @@ - - - Standard Mocha TDD structure for integration tests - - Basic Test Suite Structure - - ```typescript - import { suite, test, suiteSetup, suiteTeardown } from 'mocha'; - import * as assert from 'assert'; - import * as vscode from 'vscode'; - import { waitFor, waitUntilCompleted, waitUntilAborted } from '../utils/testUtils'; - - suite('Feature Name Tests', () => { - let testWorkspaceDir: string; - let testFiles: { [key: string]: string } = {}; - - suiteSetup(async () => { - // Setup test workspace and files - testWorkspaceDir = vscode.workspace.workspaceFolders![0].uri.fsPath; - // Create test files in workspace - }); - - suiteTeardown(async () => { - // Cleanup test files and tasks - await api.cancelCurrentTask(); - }); - - test('should perform specific functionality', async () => { - // Test implementation - }); - }); - ``` - - - - - Event Listening Pattern - - ```typescript - test('should handle task completion events', async () => { - const events: any[] = []; - - const messageListener = (message: any) => { - events.push({ type: 'message', data: message }); - }; - - const taskCompletedListener = (result: any) => { - events.push({ type: 'taskCompleted', data: result }); - }; - - api.onDidReceiveMessage(messageListener); - api.onTaskCompleted(taskCompletedListener); - - try { - // Perform test actions - await api.startTask('test prompt'); - await waitUntilCompleted(); - - // Validate events - assert(events.some(e => e.type === 'taskCompleted')); - } finally { - // Cleanup listeners - api.onDidReceiveMessage(() => {}); - api.onTaskCompleted(() => {}); - } - }); - ``` - - - - - File Creation Test Pattern - - ```typescript - test('should create files in workspace', async () => { - const fileName = 'test-file.txt'; - const expectedContent = 'test content'; - - await api.startTask(`Create a file named ${fileName} with content: ${expectedContent}`); - await waitUntilCompleted(); - - // Check multiple possible locations - const possiblePaths = [ - path.join(testWorkspaceDir, fileName), - path.join(process.cwd(), fileName), - // Add other possible locations - ]; - - let fileFound = false; - let actualContent = ''; - - for (const filePath of possiblePaths) { - if (fs.existsSync(filePath)) { - actualContent = fs.readFileSync(filePath, 'utf8'); - fileFound = true; - break; - } - } - - assert(fileFound, `File ${fileName} not found in any expected location`); - assert.strictEqual(actualContent.trim(), expectedContent); - }); - ``` - - - - - - - Basic Task Execution - - ```typescript - // Start a task and wait for completion - await api.startTask('Your prompt here'); - await waitUntilCompleted(); - ``` - - - - - Task with Auto-Approval Settings - - ```typescript - // Enable auto-approval for specific actions - await api.updateSettings({ - alwaysAllowWrite: true, - alwaysAllowExecute: true - }); - - await api.startTask('Create and execute a script'); - await waitUntilCompleted(); - ``` - - - - - Message Validation - - ```typescript - const messages: any[] = []; - api.onDidReceiveMessage((message) => { - messages.push(message); - }); - - await api.startTask('test prompt'); - await waitUntilCompleted(); - - // Validate specific message types - const toolMessages = messages.filter(m => - m.type === 'say' && m.say === 'api_req_started' - ); - assert(toolMessages.length > 0, 'Expected tool execution messages'); - ``` - - - - - - - Task Abortion Handling - - ```typescript - test('should handle task abortion', async () => { - await api.startTask('long running task'); - - // Abort after short delay - setTimeout(() => api.abortTask(), 1000); - - await waitUntilAborted(); - - // Verify task was properly aborted - const status = await api.getTaskStatus(); - assert.strictEqual(status, 'aborted'); - }); - ``` - - - - - Error Message Validation - - ```typescript - test('should handle invalid input gracefully', async () => { - const errorMessages: any[] = []; - - api.onDidReceiveMessage((message) => { - if (message.type === 'error' || message.text?.includes('error')) { - errorMessages.push(message); - } - }); - - await api.startTask('invalid prompt that should fail'); - await waitFor(() => errorMessages.length > 0, 5000); - - assert(errorMessages.length > 0, 'Expected error messages'); - }); - ``` - - - - - - - File Location Helper - - ```typescript - function findFileInWorkspace(fileName: string, workspaceDir: string): string | null { - const possiblePaths = [ - path.join(workspaceDir, fileName), - path.join(process.cwd(), fileName), - path.join(os.tmpdir(), fileName), - // Add other common locations - ]; - - for (const filePath of possiblePaths) { - if (fs.existsSync(filePath)) { - return filePath; - } - } - - return null; - } - ``` - - - - - Event Collection Helper - - ```typescript - class EventCollector { - private events: any[] = []; - - constructor(private api: any) { - this.setupListeners(); - } - - private setupListeners() { - this.api.onDidReceiveMessage((message: any) => { - this.events.push({ type: 'message', timestamp: Date.now(), data: message }); - }); - - this.api.onTaskCompleted((result: any) => { - this.events.push({ type: 'taskCompleted', timestamp: Date.now(), data: result }); - }); - } - - getEvents(type?: string) { - return type ? this.events.filter(e => e.type === type) : this.events; - } - - clear() { - this.events = []; - } - } - ``` - - - - - - - Comprehensive Logging - - ```typescript - test('should log execution flow for debugging', async () => { - console.log('Starting test execution'); - - const events: any[] = []; - api.onDidReceiveMessage((message) => { - console.log('Received message:', JSON.stringify(message, null, 2)); - events.push(message); - }); - - console.log('Starting task with prompt'); - await api.startTask('test prompt'); - - console.log('Waiting for task completion'); - await waitUntilCompleted(); - - console.log('Task completed, events received:', events.length); - console.log('Final workspace state:', fs.readdirSync(testWorkspaceDir)); - }); - ``` - - - - - State Validation - - ```typescript - function validateTestState(description: string) { - console.log(`=== ${description} ===`); - console.log('Workspace files:', fs.readdirSync(testWorkspaceDir)); - console.log('Current working directory:', process.cwd()); - console.log('Task status:', api.getTaskStatus?.() || 'unknown'); - console.log('========================'); - } - ``` - - - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/3_best_practices.xml b/.roo/rules-integration-tester/3_best_practices.xml deleted file mode 100644 index e495ea5f0ae..00000000000 --- a/.roo/rules-integration-tester/3_best_practices.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - Always use suite() and test() blocks following Mocha TDD style - - Use descriptive test names that explain the scenario being tested - - Implement proper setup and teardown in suiteSetup() and suiteTeardown() - - Create test files in the VSCode workspace directory during suiteSetup() - - Store file paths in a test-scoped object for easy reference across tests - - Ensure tests are independent and can run in any order - - Clean up all test files and tasks in suiteTeardown() to avoid test pollution - - - - - Always use the global api object for extension interactions - - Implement proper async/await patterns with the waitFor utility - - Use waitUntilCompleted and waitUntilAborted helpers for task monitoring - - Set appropriate auto-approval settings (alwaysAllowWrite, alwaysAllowExecute) for the functionality being tested - - Listen to and validate appropriate events (message, taskCompleted, taskAborted, etc.) - - Always clean up tasks with cancelCurrentTask or clearCurrentTask after tests - - Use meaningful timeouts that account for actual task execution time - - - - - Be aware that files may be created in the workspace directory (/tmp/roo-test-workspace-*) rather than expected locations - - Always check multiple possible file locations when verifying file creation - - Use flexible file location checking that searches workspace directories - - Verify files exist after creation to catch setup issues early - - Account for the fact that the workspace directory is created by runTest.ts - - The AI may use internal tools instead of the documented tools - verify outcomes rather than methods - - - - - Add multiple event listeners (taskStarted, taskCompleted, taskAborted) for better debugging - - Don't rely on parsing AI messages to detect tool usage - the AI's message format may vary - - Use terminal shell execution events (onDidStartTerminalShellExecution, onDidEndTerminalShellExecution) for command tracking - - Tool executions are reported via api_req_started messages with type="say" and say="api_req_started" - - Focus on testing outcomes (files created, commands executed) rather than message parsing - - There is no "tool_result" message type - tool results appear in "completion_result" or "text" messages - - - - - Test both positive flows and error scenarios - - Validate message content using proper type assertions - - Implement proper error handling and edge cases - - Use try-catch blocks around critical test operations - - Log important events like task IDs, file paths, and AI responses for debugging - - Check test output carefully for error messages and stack traces - - - - - Remove unnecessary waits for specific tool executions - wait for task completion instead - - Simplify message handlers to only capture essential error information - - Use the simplest possible test structure that verifies the outcome - - Avoid complex message parsing logic that depends on AI behavior - - Terminal events are more reliable than message parsing for command execution verification - - Keep prompts simple and direct - complex instructions may confuse the AI - - - - - Add console.log statements to track test execution flow - - Log important events like task IDs, file paths, and AI responses - - Use codebase_search first to find similar test patterns before writing new tests - - Create helper functions for common file location checks - - Use descriptive variable names for file paths and content - - Always log the expected vs actual locations when tests fail - - Add comprehensive comments explaining complex test logic - - - - - Create reusable test utilities when patterns emerge - - Implement helper functions for common operations like file finding - - Use event collection utilities for consistent event handling - - Create assertion helpers for common validation patterns - - Document any new test utilities or patterns created - - Share common utilities across test files to reduce duplication - - - - - Keep prompts simple and direct - complex instructions may lead to unexpected behavior - - Allow for variations in how the AI accomplishes tasks - - The AI may not always use the exact tool you specify in the prompt - - Be prepared to adapt tests based on actual AI behavior rather than expected behavior - - The AI may interpret instructions creatively - test results rather than implementation details - - The AI will not see the files in the workspace directory, you must tell it to assume they exist and proceed - - - - - ALWAYS use the correct working directory: apps/vscode-e2e - - The test command is: npm run test:run - - To run specific tests use environment variable: TEST_FILE="filename.test" npm run test:run - - Example: cd apps/vscode-e2e && TEST_FILE="apply-diff.test" npm run test:run - - Never use npm test directly as it doesn't exist - - Always check available scripts with npm run if unsure - - Run tests incrementally during development to catch issues early - - - - - Never write a test file with a single write_to_file tool call - - Always implement tests in parts: structure first, then individual test cases - - Group related tests in the same suite - - Use consistent naming conventions for test files and functions - - Separate test utilities into their own files when they become substantial - - Follow the existing project structure and conventions - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/4_common_mistakes.xml b/.roo/rules-integration-tester/4_common_mistakes.xml deleted file mode 100644 index 88a74736432..00000000000 --- a/.roo/rules-integration-tester/4_common_mistakes.xml +++ /dev/null @@ -1,109 +0,0 @@ - - - - Writing a test file with a single write_to_file tool call instead of implementing in parts - - Not using proper Mocha TDD structure with suite() and test() blocks - - Forgetting to implement suiteSetup() and suiteTeardown() for proper cleanup - - Creating tests that depend on each other or specific execution order - - Not cleaning up tasks and files after test completion - - Using describe/it blocks instead of the required suite/test blocks - - - - - Not using the global api object for extension interactions - - Forgetting to set auto-approval settings (alwaysAllowWrite, alwaysAllowExecute) when testing functionality that requires user approval - - Not implementing proper async/await patterns with waitFor utilities - - Using incorrect timeout values that are too short for actual task execution - - Not properly cleaning up tasks with cancelCurrentTask or clearCurrentTask - - Assuming the AI will use specific tools instead of testing outcomes - - - - - Assuming files will be created in the expected location without checking multiple paths - - Not accounting for the workspace directory being created by runTest.ts - - Creating test files in temporary directories instead of the VSCode workspace directory - - Not verifying files exist after creation during setup - - Forgetting that the AI may not see files in the workspace directory - - Not using flexible file location checking that searches workspace directories - - - - - Relying on parsing AI messages to detect tool usage instead of using proper event listeners - - Expecting tool results in "tool_result" message type (which doesn't exist) - - Not listening to terminal shell execution events for command tracking - - Depending on specific message formats that may vary - - Not implementing proper event cleanup after tests - - Parsing complex AI conversation messages instead of focusing on outcomes - - - - - Using npm test instead of npm run test:run - - Not using the correct working directory (apps/vscode-e2e) - - Running tests from the wrong directory - - Not checking available scripts with npm run when unsure - - Forgetting to use TEST_FILE environment variable for specific tests - - Not running tests incrementally during development - - - - - Not adding sufficient logging to track test execution flow - - Not logging important events like task IDs, file paths, and AI responses - - Not using codebase_search to find similar test patterns before writing new tests - - Not checking test output carefully for error messages and stack traces - - Not validating test state at critical points - - Assuming test failures are due to code issues without checking test logic - - - - - Using complex instructions that may confuse the AI - - Expecting the AI to use exact tools specified in prompts - - Not allowing for variations in how the AI accomplishes tasks - - Testing implementation details instead of outcomes - - Not adapting tests based on actual AI behavior - - Forgetting to tell the AI to assume files exist in the workspace directory - - - - - Adding unnecessary waits for specific tool executions - - Using complex message parsing logic that depends on AI behavior - - Not using the simplest possible test structure - - Depending on specific AI message formats - - Not using terminal events for reliable command execution verification - - Making tests too brittle by depending on exact AI responses - - - - - Not understanding that files may be created in /tmp/roo-test-workspace-* directories - - Assuming the AI can see files in the workspace directory - - Not checking multiple possible file locations when verifying creation - - Creating files outside the VSCode workspace during tests - - Not properly setting up the test workspace in suiteSetup() - - Forgetting to clean up workspace files in suiteTeardown() - - - - - Expecting specific message types for tool execution results - - Not understanding that ClineMessage types have specific values - - Trying to parse tool execution from AI conversation messages - - Not checking packages/types/src/message.ts for valid message types - - Depending on message parsing instead of outcome verification - - Not using api_req_started messages to verify tool execution - - - - - Using timeouts that are too short for actual task execution - - Not accounting for AI processing time in test timeouts - - Waiting for specific tool executions instead of task completion - - Not implementing proper retry logic for flaky operations - - Using fixed delays instead of condition-based waiting - - Not considering that some operations may take longer in CI environments - - - - - Not creating test files in the correct workspace directory - - Using hardcoded paths that don't work across different environments - - Not storing file paths in test-scoped objects for easy reference - - Creating test data that conflicts with other tests - - Not cleaning up test data properly after tests complete - - Using test data that's too complex for the AI to handle reliably - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/5_test_environment.xml b/.roo/rules-integration-tester/5_test_environment.xml deleted file mode 100644 index 8e872b1dfc4..00000000000 --- a/.roo/rules-integration-tester/5_test_environment.xml +++ /dev/null @@ -1,209 +0,0 @@ - - - VSCode E2E testing framework using Mocha and VSCode Test - - - Mocha TDD framework for test structure - - VSCode Test framework for extension testing - - Custom test utilities and helpers - - Event-driven testing patterns - - Workspace-based test execution - - - - - apps/vscode-e2e/src/suite/ - apps/vscode-e2e/src/utils/ - apps/vscode-e2e/src/runTest.ts - apps/vscode-e2e/package.json - packages/types/ - - - - apps/vscode-e2e - - npm run test:run - TEST_FILE="filename.test" npm run test:run - cd apps/vscode-e2e && TEST_FILE="apply-diff.test" npm run test:run - npm run - - - - Never use npm test directly as it doesn't exist - - Always use the correct working directory - - Use TEST_FILE environment variable for specific tests - - Check available scripts with npm run if unsure - - - - - Global api object for extension interactions - - - - api.startTask(prompt: string): Start a new task - - api.cancelCurrentTask(): Cancel the current task - - api.clearCurrentTask(): Clear the current task - - api.abortTask(): Abort the current task - - api.getTaskStatus(): Get current task status - - - - api.onDidReceiveMessage(callback): Listen to messages - - api.onTaskCompleted(callback): Listen to task completion - - api.onTaskAborted(callback): Listen to task abortion - - api.onTaskStarted(callback): Listen to task start - - api.onDidStartTerminalShellExecution(callback): Terminal start events - - api.onDidEndTerminalShellExecution(callback): Terminal end events - - - - api.updateSettings(settings): Update extension settings - - api.getSettings(): Get current settings - - - - - - - - Wait for a condition to be true - await waitFor(() => condition, timeout) - await waitFor(() => fs.existsSync(filePath), 5000) - - - Wait until current task is completed - await waitUntilCompleted() - Default timeout for task completion - - - Wait until current task is aborted - await waitUntilAborted() - Default timeout for task abortion - - - - - - Helper to find files in multiple possible locations - Use when files might be created in different workspace directories - - - Utility to collect and analyze events during test execution - Use for comprehensive event tracking and validation - - - Custom assertion functions for common test patterns - Use for consistent validation across tests - - - - - - - Test workspaces are created by runTest.ts - /tmp/roo-test-workspace-* - vscode.workspace.workspaceFolders![0].uri.fsPath - - - - Create all test files in suiteSetup() before any tests run - Always create files in the VSCode workspace directory - Verify files exist after creation to catch setup issues early - Clean up all test files in suiteTeardown() to avoid test pollution - Store file paths in a test-scoped object for easy reference - - - - The AI will not see the files in the workspace directory - Tell the AI to assume files exist and proceed as if they do - Always verify outcomes rather than relying on AI file visibility - - - - - Understanding message types for proper event handling - Check packages/types/src/message.ts for valid message types - - - - say - api_req_started - Indicates tool execution started - JSON with tool name and execution details - Most reliable way to verify tool execution - - - - Contains tool execution results - Tool results appear here, not in "tool_result" type - - - - General AI conversation messages - Format may vary, don't rely on parsing these for tool detection - - - - - - Settings to enable automatic approval of AI actions - - Enable for file creation/modification tests - Enable for command execution tests - Enable for browser-related tests - - - ```typescript - await api.updateSettings({ - alwaysAllowWrite: true, - alwaysAllowExecute: true - }); - ``` - - Without proper auto-approval settings, the AI won't be able to perform actions without user approval - - - - - Use console.log for tracking test execution flow - - - Log test phase transitions - - Log important events and data - - Log file paths and workspace state - - Log expected vs actual outcomes - - - - - Helper functions to validate test state at critical points - - - Workspace file listing - - Current working directory - - Task status - - Event counts - - - - - Tools for analyzing test failures - - - Stack trace analysis - - Event timeline reconstruction - - File system state comparison - - Message flow analysis - - - - - - - Appropriate timeout values for different operations - Use generous timeouts for task completion (30+ seconds) - Shorter timeouts for file system operations (5-10 seconds) - Medium timeouts for event waiting (10-15 seconds) - - - - Proper cleanup to avoid resource leaks - Always clean up event listeners after tests - Cancel or clear tasks in teardown - Remove test files to avoid disk space issues - - - \ No newline at end of file diff --git a/.roo/rules-issue-investigator/1_workflow.xml b/.roo/rules-issue-investigator/1_workflow.xml index 561b2751202..4d2528e7753 100644 --- a/.roo/rules-issue-investigator/1_workflow.xml +++ b/.roo/rules-issue-investigator/1_workflow.xml @@ -70,7 +70,7 @@ Draft Comment - Draft a comment for the GitHub issue that explains your findings and suggested solution in a conversational, human-like tone. + Draft a comment for the GitHub issue that explains your findings and suggested solution in a conversational, human-like tone. Start the comment with "Hey @roomote-agent,". diff --git a/.roo/rules-issue-investigator/2_best_practices.xml b/.roo/rules-issue-investigator/2_best_practices.xml index 31ad2c22678..1445822ccdd 100644 --- a/.roo/rules-issue-investigator/2_best_practices.xml +++ b/.roo/rules-issue-investigator/2_best_practices.xml @@ -52,6 +52,7 @@ Does the draft comment sound conversational and human? + Does the draft comment start with "Hey @roomote-agent,"? Have I avoided technical jargon where possible? Is the tone helpful and not condescending? diff --git a/.roo/rules-issue-investigator/4_tool_usage.xml b/.roo/rules-issue-investigator/4_tool_usage.xml index c43c41a8c36..f34f57f5ffd 100644 --- a/.roo/rules-issue-investigator/4_tool_usage.xml +++ b/.roo/rules-issue-investigator/4_tool_usage.xml @@ -23,10 +23,16 @@ Use `gh issue view [URL] --json title,body,labels,comments` to fetch initial details. Use `gh issue comment [URL] --body "..."` to add comments, but only after explicit user approval. Always wrap the comment body in quotes to handle special characters. + When posting a comment, the body must start with "Hey @roomote-agent," exactly. gh issue view https://github.com/RooCodeInc/Roo-Code/issues/123 --json title,body + + ]]> + +gh issue comment https://github.com/RooCodeInc/Roo-Code/issues/123 --body "Hey @roomote-agent, I've investigated and proposed a theoretical fix above." ]]> diff --git a/.roo/rules-issue-investigator/5_examples.xml b/.roo/rules-issue-investigator/5_examples.xml index dbd4ae2f23c..af417e3e69d 100644 --- a/.roo/rules-issue-investigator/5_examples.xml +++ b/.roo/rules-issue-investigator/5_examples.xml @@ -70,7 +70,7 @@ Formulate a solution and draft a comment. -"Hey, I took a look at this issue and it seems like the problem is in the `handleLogin` function in `src/auth/login.ts`. There's an empty `try...catch` block that is likely catching the login error and preventing any message from being shown to the user. +"Hey @roomote-agent, I took a look at this issue and it seems like the problem is in the `handleLogin` function in `src/auth/login.ts`. There's an empty `try...catch` block that is likely catching the login error and preventing any message from being shown to the user. A good first step to fixing this would be to add some logging to the `catch` block to see what error is actually happening. Then, we should probably implement proper error feedback to the user on the login page. This should solve the issue of the silent failure." diff --git a/.roo/rules-issue-investigator/6_communication.xml b/.roo/rules-issue-investigator/6_communication.xml index 348ecf18782..6ed68c0043b 100644 --- a/.roo/rules-issue-investigator/6_communication.xml +++ b/.roo/rules-issue-investigator/6_communication.xml @@ -2,18 +2,20 @@ Be conversational and helpful, not robotic. Write comments as if you are a human developer collaborating on the project. + Always start any GitHub issue comment body with "Hey @roomote-agent,". Analysis complete. The investigation has yielded the following result. + Starting comments with "Hey," instead of "Hey @roomote-agent,". - Hey, I took a look at this and found something interesting... - I've been digging into this issue, and I think I've found a possible cause. + Hey @roomote-agent, I took a look at this and found something interesting... + Hey @roomote-agent, I've been digging into this issue, and I think I've found a possible cause. - Start with a friendly opening. + Start every GitHub issue comment with "Hey @roomote-agent,". State your main finding or hypothesis clearly but not definitively. Provide context, like file paths and function names. Propose a next step or a theoretical solution. diff --git a/.roo/rules-issue-writer/1_workflow.xml b/.roo/rules-issue-writer/1_workflow.xml index 99ef7db5d9d..0dc2e279d2a 100644 --- a/.roo/rules-issue-writer/1_workflow.xml +++ b/.roo/rules-issue-writer/1_workflow.xml @@ -1,1161 +1,391 @@ + + This mode focuses solely on assembling a template-free GitHub issue prompt for an AI coding agent. + It integrates codebase exploration to ground the prompt in reality while keeping the output non-technical. + It also captures the user-facing value/impact (who is affected, how often, and why it matters) to support prioritization, all in plain language. + + + + + - Codebase exploration is iterative and may repeat as many times as needed based on user-agent back-and-forth. + - Early-stop and escalate-once apply per iteration; when new info arrives, start a fresh iteration. + - One-tool-per-message is respected; narrate succinct progress and update TODOs each iteration. + + + - New details from the user (environment, steps, screenshots, constraints) + - Clarifications that change scope or target component/feature + - Discrepancies found between user claims and code + - Reclassification between Bug and Enhancement + + + + + - Treat the user's FIRST message as the issue description; do not ask if they want to create an issue. + - Begin immediately: initialize a focused TODO list and start repository detection before discovery. + - CLI submission via gh happens only after the user confirms during the merged review/submit step. + + + + [ ] Detect repository context (OWNER/REPO, monorepo, roots) + [ ] Perform targeted codebase discovery (iteration 1) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + + - Initialize Issue Creation Process + Kickoff - IMPORTANT: This mode assumes the first user message is already a request to create an issue. - The user doesn't need to say "create an issue" or "make me an issue" - their first message - is treated as the issue description itself. - - When the session starts, immediately: - 1. Treat the user's first message as the issue description - 2. Initialize the workflow by using the update_todo_list tool - 3. Begin the issue creation process without asking what they want to do - - - - [ ] Detect current repository information - [ ] Determine repository structure (monorepo/standard) - [ ] Perform initial codebase discovery - [ ] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - + Rephrase the user's goal and outline a brief plan, then proceed without delay. + Maintain low narrative verbosity; use structured outputs for details. - - - - Detect current repository information - - CRITICAL FIRST STEP: Verify we're in a git repository and get repository information. - - 1. Check if we're in a git repository: - - git rev-parse --is-inside-work-tree 2>/dev/null || echo "not-git-repo" - - - If the output is "not-git-repo", immediately stop and inform the user: - - - - This mode must be run from within a GitHub repository. Please navigate to a git repository and try again. - - - - 2. If in a git repository, get the repository information: - - git remote get-url origin 2>/dev/null | sed -E 's/.*[:/]([^/]+)\/([^/]+)(\.git)?$/\1\/\2/' | sed 's/\.git$//' - - - Store this as REPO_FULL_NAME for use throughout the workflow. - - If no origin remote exists, stop with: - - - No GitHub remote found. This mode requires a GitHub repository with an 'origin' remote configured. - - - - Update todo after detecting repository: - - - [x] Detect current repository information - [-] Determine repository structure (monorepo/standard) - [ ] Perform initial codebase discovery - [ ] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - - - Determine Repository Structure - - Check if this is a monorepo or standard repository by looking for common patterns. - - First, check for monorepo indicators: - 1. Look for workspace configuration: - - package.json with "workspaces" field - - lerna.json - - pnpm-workspace.yaml - - rush.json - - 2. Check for common monorepo directory patterns: - - . - false - - - Look for directories like: - - apps/ (application packages) - - packages/ (shared packages) - - services/ (service packages) - - libs/ (library packages) - - modules/ (module packages) - - src/ (main source if not using workspaces) - - If monorepo detected: - - Dynamically discover packages by looking for package.json files in detected directories - - Build a list of available packages with their paths - - Based on the user's description, try to identify which package they're referring to. - If unclear, ask for clarification: - - - I see this is a monorepo with multiple packages. Which specific package or application is your issue related to? - - [Dynamically generated list of discovered packages] - Let me describe which package: [specify] - - - - If standard repository: - - Skip package selection - - Use repository root for all searches - - Store the repository context for all future codebase searches and explorations. - - Update todo after determining context: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [-] Perform initial codebase discovery - [ ] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - - - - Perform Initial Codebase Discovery - - Now that we know the repository structure, immediately search the codebase to understand - what the user is talking about before determining the issue type. - - DISCOVERY ACTIVITIES: - - 1. Extract keywords and concepts from the user's INITIAL MESSAGE (their issue description) - 2. Search the codebase to verify these concepts exist - 3. Build understanding of the actual implementation - 4. Identify relevant files, components, and code patterns - - - [Keywords from user's initial message/description] - [Repository or package path from step 2] - - - Additional searches based on initial findings: - - If error mentioned: search for exact error strings - - If feature mentioned: search for related functionality - - If component mentioned: search for implementation details - - - [repository or package path] - [specific patterns found in initial search] - - - Document findings: - - Components/features found that match user's description - - Actual implementation details discovered - - Related code sections identified - - Any discrepancies between user description and code reality - - Update todos: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [-] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - - - - Analyze Request to Determine Issue Type - - Using the codebase discoveries from step 2, analyze the user's request to determine - the appropriate issue type with informed context. - - CRITICAL GUIDANCE FOR ISSUE TYPE SELECTION: - For issues that affect user workflows or require behavior changes: - - PREFER the feature proposal template over bug report - - Focus on explaining WHO is affected and WHEN this happens - - Describe the user impact before diving into technical details - - Based on your findings, classify the issue: - - Bug indicators (verified against code): - - Error messages that match actual error handling in code - - Broken functionality in existing features found in codebase - - Regression from previous behavior documented in code/tests - - Code paths that don't work as documented - - Feature indicators (verified against code): - - New functionality not found in current codebase - - Enhancement to existing features found in code - - Missing capabilities compared to similar features - - Integration points that could be extended - - WORKFLOW IMPROVEMENTS: When existing behavior works but doesn't meet user needs - - IMPORTANT: Use your codebase findings to inform the question: - - - Based on your request about [specific feature/component found in code], what type of issue would you like to create? - - [Order based on codebase findings and user description] - Bug Report - [Specific component] is not working as expected - Feature Proposal - Add [specific capability] to [existing component] - - - - Update todos: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [-] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - - - - Gather and Verify Additional Information - - Based on the issue type and initial codebase discovery, gather information while - continuously verifying against the actual code implementation. - - CRITICAL FOR FEATURE REQUESTS: Be fact-driven and challenge assumptions! - When users describe current behavior as problematic for a feature request, you MUST verify - their claims against the actual code. If their description doesn't match reality, this - might actually be a bug report, not a feature request. - - For Bug Reports: - 1. When user describes steps to reproduce: - - Search for the UI components/commands mentioned - - Verify the code paths that would be executed - - Check for existing error handling or known issues - - 2. When user provides error messages: - - Search for exact error strings in codebase - - Find where errors are thrown - - Understand the conditions that trigger them - - 3. For version information: - - Check package.json for actual version - - Look for version-specific code or migrations - - Example verification searches: - - [repository or package path] - [exact error message from user] - - - - [feature or component name] implementation - [repository or package path] - - - For Feature Requests - AGGRESSIVE VERIFICATION WITH CONCRETE EXAMPLES: - 1. When user claims current behavior is X: - - ALWAYS search for the actual implementation - - Read the relevant code to verify their claim - - Check CSS/styling files if UI-related - - Look at configuration files - - Examine test files to understand expected behavior - - TRACE THE DATA FLOW: Follow values from where they're calculated to where they're used - - 2. CRITICAL: Look for existing variables/code that could be reused: - - Search for variables that are calculated but not used where expected - - Identify existing patterns that could be extended - - Find similar features that work correctly for comparison - - 3. If discrepancy found between claim and code: - - Do NOT proceed without clarification - - Present CONCRETE before/after examples with actual values - - Show exactly what happens vs what should happen - - Ask if this might be a bug instead - - Example verification approach: - User says: "Feature X doesn't work properly" - - Your investigation should follow this pattern: - a) What is calculated: Search for where X is computed/defined - b) Where it's stored: Find variables/state holding the value - c) Where it's used: Trace all usages of that value - d) What's missing: Identify gaps in the flow - - Present findings with concrete examples: - - - I investigated the implementation and found something interesting: - - Current behavior: - - The value is calculated at [file:line]: `value = computeX()` - - It's stored in variable `calculatedValue` at [file:line] - - BUT it's only used for [purpose A] at [file:line] - - It's NOT used for [purpose B] where you expected it - - Concrete example: - - When you do [action], the system calculates [value] - - This value goes to [location A] - - But [location B] still uses [old/different value] - - Is this the issue you're experiencing? This seems like the calculated value isn't being used where it should be. - - Yes, exactly! The value is calculated but not used in the right place - No, the issue is that the calculation itself is wrong - Actually, I see now that [location B] should use a different value - - - - 4. Continue verification until facts are established: - - If user confirms it's a bug, switch to bug report workflow - - If user provides more specific context, search again - - Do not accept vague claims without code verification - - 5. For genuine feature requests after verification: - - Document what the code currently does (with evidence and line numbers) - - Show the exact data flow: input → processing → output - - Confirm what the user wants changed with concrete examples - - Ensure the request is based on accurate understanding - - CRITICAL: For feature requests, if user's description doesn't match codebase reality: - - Challenge the assumption with code evidence AND concrete examples - - Show actual vs expected behavior with specific values - - Suggest it might be a bug if code shows different intent - - Ask for clarification repeatedly if needed - - Do NOT proceed until facts are established - - Only proceed when you have: - - Verified current behavior in code with line-by-line analysis - - Confirmed user's understanding matches reality - - Determined if it's truly a feature request or actually a bug - - Identified any existing code that could be reused for the fix - - Update todos after verification: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [-] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - - - - Determine Contribution Intent with Context - - Before asking about contribution, perform a quick technical assessment to provide context: - - 1. Search for complexity indicators: - - Number of files that would need changes - - Existing tests that would need updates - - Dependencies and integration points - - 2. Look for contribution helpers: - - CONTRIBUTING.md guidelines - - Existing similar implementations - - Test patterns to follow - - - CONTRIBUTING guide setup development - - - Based on findings, provide informed context in the question: - - - Based on my analysis, this [issue type] involves [brief complexity assessment from code exploration]. Are you interested in implementing this yourself, or are you reporting it for the project team to handle? - - Just reporting the problem - the project team can design the solution - I want to contribute and implement this myself - I'd like to provide issue scoping to help whoever implements it - - - - Update todos based on response: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [If contributing: [-] Perform issue scoping (if contributing)] - [If not contributing: [-] Perform issue scoping (skipped - not contributing)] - [-] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - - - - Issue Scoping for Contributors - - ONLY perform this step if the user wants to contribute or provide issue scoping. - - This step performs a comprehensive, aggressive investigation to create detailed technical - scoping that can guide implementation. The process involves multiple sub-phases: - - - - Perform an exhaustive investigation to produce a comprehensive technical solution - with extreme detail, suitable for automated fix workflows. - - - - Expand the todo list to include detailed investigation steps - - When starting the issue scoping phase, update the main todo list to include - the detailed investigation steps: - - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [-] Perform issue scoping (if contributing) - [ ] Extract keywords from the issue description - [ ] Perform initial broad codebase search - [ ] Analyze search results and identify key components - [ ] Deep dive into relevant files and implementations - [ ] Form initial hypothesis about the issue/feature - [ ] Attempt to disprove hypothesis through further investigation - [ ] Identify all affected files and dependencies - [ ] Map out the complete implementation approach - [ ] Document technical risks and edge cases - [ ] Formulate comprehensive technical solution - [ ] Create detailed acceptance criteria - [ ] Prepare issue scoping summary - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - - - - Extract all relevant keywords, concepts, and technical terms - - - Identify primary technical concepts from user's description - - Extract error messages or specific symptoms - - Note any mentioned file paths or components - - List related features or functionality - - Include synonyms and related terms - - - Update the main todo list to mark "Extract keywords" as complete and move to next phase - - - - - Perform multiple rounds of increasingly focused searches - - - Use codebase_search with all extracted keywords to get an overview of relevant code. - - [Combined keywords from extraction phase] - [Repository or package path] - - - - - Based on initial results, identify key components and search for: - - Related class/function definitions - - Import statements and dependencies - - Configuration files - - Test files that might reveal expected behavior - - - - Search for specific implementation details: - - Error handling patterns - - State management - - API endpoints or routes - - Database queries or models - - UI components and their interactions - - - - Look for: - - Edge cases in the code - - Integration points with other systems - - Configuration options that affect behavior - - Feature flags or conditional logic - - - - After completing all search iterations, update the todo list to show progress - - - - - Thoroughly analyze all relevant files discovered - - - Use list_code_definition_names to understand file structure - - Read complete files to understand full context - - Trace execution paths through the code - - Identify all dependencies and imports - - Map relationships between components - - - Document findings including: - - File paths and their purposes - - Key functions and their responsibilities - - Data flow through the system - - External dependencies - - Potential impact areas - - - - - Form a comprehensive hypothesis about the issue or feature - - - Identify the most likely root cause - - Trace the bug through the execution path - - Determine why the current implementation fails - - Consider environmental factors - - - - Identify the optimal integration points - - Determine required architectural changes - - Plan the implementation approach - - Consider scalability and maintainability - - - - - Aggressively attempt to disprove the hypothesis - - - - Look for similar features implemented differently - - Check for deprecated code that might interfere - - - - Search for configuration that could change behavior - - Look for environment-specific code paths - - - - Find existing tests that might contradict hypothesis - - Look for test cases that reveal edge cases - - - - Search for comments explaining design decisions - - Look for TODO or FIXME comments related to the area - - - - If hypothesis is disproven, return to search phase with new insights. - If hypothesis stands, proceed to solution formulation. - - - - - Create a comprehensive technical solution - PRIORITIZE SIMPLICITY - - CRITICAL: Before proposing any solution, ask yourself: - 1. What existing variables/functions can I reuse? - 2. What's the minimal change that fixes the issue? - 3. Can I leverage existing patterns in the codebase? - 4. Is there a simpler approach I'm overlooking? - - The best solution often reuses existing code rather than creating new complexity. - - - - ALWAYS consider backwards compatibility: - 1. Will existing data/configurations still work with the new code? - 2. Can we detect and handle legacy formats automatically? - 3. What migration paths are needed for existing users? - 4. Are there ways to make changes additive rather than breaking? - 5. Document any compatibility considerations clearly - - - - FIRST, identify what can be reused: - - Variables that are already calculated but not used where needed - - Functions that already do what we need - - Patterns in similar features we can follow - - Configuration that already exists but isn't applied - - Example finding: - "The variable `calculatedValue` already contains what we need at line X, - we just need to use it at line Y instead of recalculating" - - - - - Start with the SIMPLEST possible fix - - Exact files to modify with line numbers - - Prefer changing variable usage over creating new logic - - Specific code changes required (minimal diff) - - Order of implementation steps - - Migration strategy if needed - - - - - All files that import affected code - - API contracts that must be maintained - - Existing tests that validate current behavior - - Configuration changes required (prefer reusing existing) - - Documentation updates needed - - - - - Unit tests to add or modify - - Integration tests required - - Edge cases to test - - Performance testing needs - - Manual testing scenarios - - - - - Breaking changes identified - - Performance implications - - Security considerations - - Backward compatibility issues - - Rollback strategy - - - - - - Create extremely detailed acceptance criteria - - Given [detailed context including system state] - When [specific user or system action] - Then [exact expected outcome] - And [additional verifiable outcomes] - But [what should NOT happen] - - Include: - - Specific UI changes with exact text/behavior - - API response formats - - Database state changes - - Performance requirements - - Error handling scenarios - - - - Each criterion must be independently testable - - Include both positive and negative test cases - - Specify exact error messages and codes - - Define performance thresholds where applicable - - - - - Format the comprehensive issue scoping section - + Detect Current Repository Information + + Verify we're in a Git repository and capture the GitHub remote for safe submission. -### Proposed Implementation + 1) Check if inside a git repository: + + git rev-parse --is-inside-work-tree 2>/dev/null || echo "not-git-repo" + -#### Step 1: [First implementation step] -- File: `path/to/file.ts` -- Changes: [Specific code changes] -- Rationale: [Why this change is needed] + If the output is "not-git-repo", stop: + + + This mode must be run from within a GitHub repository. Navigate to a git repository and try again. + + -#### Step 2: [Second implementation step] -[Continue for all steps...] + 2) Get origin remote and normalize to OWNER/REPO: + + git remote get-url origin 2>/dev/null | sed -E 's/.*[:/]([^/]+)\/([^/]+)(\.git)?$/\1\/\2/' | sed 's/\.git$//' + -### Code Architecture Considerations -- Design patterns to follow -- Existing patterns in codebase to match -- Architectural constraints + If no origin remote exists, stop: + + + No GitHub 'origin' remote found. Configure a GitHub remote and retry. + + + + Record the normalized OWNER/REPO (e.g., owner/repo) as [OWNER_REPO] to pass via --repo during submission. + + 3) Combined monorepo check and roots discovery (single command): + + set -e; if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "not-git-repo"; exit 0; fi; OWNER_REPO=$(git remote get-url origin 2>/dev/null | sed -E 's/.*[:/]([^/]+)\/([^/]+)(\.git)?$/\1\/\2/' | sed 's/\.git$//'); IS_MONO=false; [ -f package.json ] && grep -q '"workspaces"' package.json && IS_MONO=true; for f in lerna.json pnpm-workspace.yaml rush.json; do [ -f "$f" ] && IS_MONO=true; done; ROOTS="."; if [ "$IS_MONO" = true ]; then ROOTS=$(git ls-files -z | tr '\0' '\n' | grep -E '^(apps|packages|services|libs)/[^/]+/package\.json$' | sed -E 's#/package\.json$##' | sort -u | paste -sd, -); [ -z "$ROOTS" ] && ROOTS=$(find . -maxdepth 3 -name package.json -not -path "./node_modules/*" -print0 | xargs -0 -n1 dirname | grep -E '^(\.|\.\/(apps|packages|services|libs)\/[^/]+)$' | sort -u | paste -sd, -); fi; echo "OWNER_REPO=$OWNER_REPO"; echo "IS_MONOREPO=$IS_MONO"; echo "ROOTS=$ROOTS" + + + Interpretation: + - If output contains OWNER_REPO, IS_MONOREPO, and ROOTS, record them and treat Step 3 as satisfied. + - If output is "not-git-repo", stop as above. + - If IS_MONOREPO=true but ROOTS is empty, perform Step 3 to determine roots manually. + + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [ ] Perform targeted codebase discovery (iteration N) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + -### Testing Requirements -- Unit Tests: - - [ ] Test case 1: [Description] - - [ ] Test case 2: [Description] -- Integration Tests: - - [ ] Test scenario 1: [Description] -- Edge Cases: - - [ ] Edge case 1: [Description] + + Determine Repository Structure (Monorepo/Standard) + + If Step 2's combined detection output includes IS_MONOREPO and ROOTS, mark this step complete and proceed to Step 4. Otherwise, use the manual process below. -### Performance Impact -- Expected performance change: [Increase/Decrease/Neutral] -- Benchmarking needed: [Yes/No, specifics] -- Optimization opportunities: [List any] + Identify whether this is a monorepo and record the search root(s). -### Security Considerations -- Input validation requirements -- Authentication/Authorization changes -- Data exposure risks + 1) List top-level entries: + + . + false + -### Migration Strategy -[If applicable, how to migrate existing data/functionality] + 2) Monorepo indicators: + - package.json with "workspaces" + - lerna.json, pnpm-workspace.yaml, rush.json + - Top-level directories like apps/, packages/, services/, libs/ -### Rollback Plan -[How to safely rollback if issues arise] + If monorepo is detected: + - Discover package roots by locating package.json files under these directories + - Prefer scoping searches to the package most aligned with the user's description + - Ask for package selection if ambiguous -### Dependencies and Breaking Changes -- External dependencies affected: [List] -- API contract changes: [List] -- Breaking changes for users: [List with mitigation] - ]]> - - - - Additional considerations for monorepo repositories: - - Scope all searches to the identified package (if monorepo) - - Check for cross-package dependencies - - Verify against package-specific conventions - - Look for package-specific configuration - - Check if changes affect multiple packages - - Identify shared dependencies that might be impacted - - Look for workspace-specific scripts or tooling - - Consider package versioning implications - - After completing the comprehensive issue scoping, update the main todo list to show - all investigation steps are complete: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Extract keywords from the issue description - [x] Perform initial broad codebase search - [x] Analyze search results and identify key components - [x] Deep dive into relevant files and implementations - [x] Form initial hypothesis about the issue/feature - [x] Attempt to disprove hypothesis through further investigation - [x] Identify all affected files and dependencies - [x] Map out the complete implementation approach - [x] Document technical risks and edge cases - [x] Formulate comprehensive technical solution - [x] Create detailed acceptance criteria - [x] Prepare issue scoping summary - [-] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + If standard repository: + - Use repository root for searches - - Check for Repository Issue Templates - - Check if the repository has custom issue templates and use them. If not, create a simple generic template. - - 1. Check for issue templates in standard locations: - - .github/ISSUE_TEMPLATE - true - - - 2. Also check for single template file: - - .github - false - - - Look for files like: - - .github/ISSUE_TEMPLATE/*.md - - .github/ISSUE_TEMPLATE/*.yml - - .github/ISSUE_TEMPLATE/*.yaml - - .github/issue_template.md - - .github/ISSUE_TEMPLATE.md - - 3. If templates are found: - a. Parse the template files to extract: - - Template name and description - - Required fields - - Template body structure - - Labels to apply - - b. For YAML templates, look for: - - name: Template display name - - description: Template description - - labels: Default labels - - body: Form fields or markdown template - - c. For Markdown templates, look for: - - Front matter with metadata - - Template structure with placeholders - - 4. If multiple templates exist, ask user to choose: - - I found the following issue templates in this repository. Which one would you like to use? - - [Template 1 name]: [Template 1 description] - [Template 2 name]: [Template 2 description] - - - - 5. If no templates are found: - - Create a simple generic template based on issue type - - For bugs: Basic structure with description, steps to reproduce, expected vs actual - - For features: Problem description, proposed solution, impact - - 6. Store the selected/created template information: - - Template content/structure - - Required fields - - Default labels - - Any special formatting requirements - - Update todos: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [-] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [-] Perform targeted codebase discovery (iteration N) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + - - Draft Issue Content - - Create the issue body using the template from step 8 and all verified information from codebase exploration. - - If using a repository template: - - Fill in the template fields with gathered information - - Include code references and findings where appropriate - - Respect the template's structure and formatting - - If using a generated template (no repo templates found): - - For Bug Reports: - ``` - ## Description - [Clear description of the bug with code context] - - ## Steps to Reproduce - 1. [Step with relevant code paths] - 2. [Step with component references] - 3. [Continue with specific details] - - ## Expected Behavior - [What should happen based on code logic] - - ## Actual Behavior - [What actually happens] - - ## Additional Context - - Version: [from package.json if found] - - Environment: [any relevant details] - - Error logs: [if any] - - ## Code Investigation - [Include findings from codebase exploration] - - Relevant files: [list with line numbers] - - Possible cause: [hypothesis from code review] - - [If user is contributing, add the comprehensive issue scoping section from step 7] - ``` - - For Feature Requests: - ``` - ## Problem Description - [What problem does this solve, who is affected, when it happens] - - ## Current Behavior - [How it works now with specific examples] - - ## Proposed Solution - [What should change] - - ## Impact - [Who benefits and how] - - ## Technical Context - [Findings from codebase exploration] - - Similar features: [code references] - - Integration points: [from exploration] - - Architecture considerations: [if any] - - [If contributing, add the comprehensive issue scoping section from step 7] - ``` - - Update todos: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [x] Draft issue content - [-] Review and confirm with user - [ ] Create GitHub issue - - - - + + Codebase-Aware Context Discovery (Iterative) + + Purpose: Understand the context of the user's description by exploring the codebase. This step is repeatable. + + Discovery workflow (respect one-tool-per-message): + 1) Extract keywords, component names, error phrases, and concepts from the user's message or latest reply. + 2) Run semantic search: + + [Keywords from user's description or latest reply] + + + 3) Refine with targeted regex where helpful: + + . + [exact error strings|component names|feature flags] + + + 4) Read key files for verification when necessary: + + [relevant file path from search hits] + + + Guidance: + - Early-stop per iteration when top hits converge (~70%) or you can name the exact feature/component involved. + - Escalate-once per iteration if signals conflict: run one refined batch, then proceed. + - Keep findings internal; do NOT include file paths, line numbers, stack traces, or diffs in the final prompt. + + Iteration rules: + - After ANY new user input or clarification, return to this step with updated keywords. + - Update internal notes and TODOs to reflect the current iteration (e.g., iteration 2, 3, ...). - - Review and Confirm with User - - Present the complete drafted issue to the user for review, highlighting the - code-verified information: - - - I've prepared the following GitHub issue based on my analysis of the codebase and your description. I've verified the technical details against the actual implementation. Please review: + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [-] Perform targeted codebase discovery (iteration N) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + - [Show the complete formatted issue content] + + Clarify Missing Details (Guided by Findings) + + Ask minimal, targeted questions grounded by what you found in code. + + For Bug reports: + + I’m verifying the behavior around [feature/component inferred from code]. Could you provide a minimal reproduction and quick impact details? + + Repro format: 1) Environment/setup 2) Steps 3) Expected 4) Actual 5) Variations (only if you tried them) + Impact: Who is affected and how often does this happen? + Cost: Approximate time or outcome cost per occurrence (optional) + + + + For Enhancements: + + To capture the improvement well, what is the user goal and value in plain language? + + State the user goal and when it occurs + Describe the desired behavior conceptually (no code) + Value: Who benefits and what improves (speed, clarity, fewer errors, conversions)? + + + + Discrepancies: + - If you found contradictions between description and code, present concrete, plain-language examples (no code) and ask for confirmation. + + Loop-back: + - After receiving any answer, return to Step 4 (Discovery) with the new information and repeat as needed. - Key verifications made: - - ✓ Component locations confirmed in code - - ✓ Error messages matched to source - - ✓ Architecture compatibility checked - [List other relevant verifications] + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [x] Perform targeted codebase discovery (iteration N) + [-] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + - Would you like me to create this issue, or would you like to make any changes? - - Yes, create this issue in the detected repository - Modify the problem description - Add more technical details - Change the title to: [let me specify] - - - - If user requests changes, make them and show the updated version for confirmation. - - After confirmation: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [x] Draft issue content - [x] Review and confirm with user - [-] Prepare issue for submission - [ ] Handle submission choice - - - - + + Classify Type (Provisional and Repeatable) + + Use the user's description plus verified findings to choose: + - Bug indicators: matched error strings; broken behavior in existing features; regression indicators. + - Enhancement indicators: capability absent; extension of existing feature; workflow improvement. + - Impact snapshot (optional): Severity (Blocker/High/Medium/Low) and Reach (Few/Some/Many). If uncertain, omit and proceed. + + Confirm with the user if uncertain: + + Based on the behavior around [feature/component], should we frame this as a Bug or an Enhancement? + + Bug Report + Enhancement + + + + Reclassification: + - If later evidence or user info changes the type, reclassify and loop back to Step 4 for a fresh discovery iteration. - - Prepare Issue for Submission - - Once user confirms the issue content, prepare it for submission: - - First, perform final duplicate check with refined search based on our findings: - - gh issue list --repo $REPO_FULL_NAME --search "[key terms from verified analysis]" --state all --limit 10 - - - If no exact duplicates are found, save the issue content to a temporary file within the project: - - - ./github_issue_draft.md - [The complete formatted issue body from step 8] - [calculated line count] - - - After saving the issue draft, ask the user how they would like to proceed: - - - I've saved the issue draft to ./github_issue_draft.md. The issue is ready for submission with the following details: + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [x] Perform targeted codebase discovery (iteration N) + [x] Clarify missing details (repro or desired outcome) + [-] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + - Title: "[Descriptive title with component name]" - Labels: [appropriate labels based on issue type] - Repository: $REPO_FULL_NAME + + Assemble Issue Body + + Build a concise, non-technical issue body. Omit empty sections entirely. + + Format: + ``` + ## Type + Bug | Enhancement + + ## Problem / Value + [One or two sentences that capture the problem and why it matters in plain language] + + ## Context + [Who is affected and when it happens] + [Enhancement: desired behavior conceptually, in the user's words] + [Bug: current observed behavior in plain language] + + ## Reproduction (Bug only, if available) + 1) Steps (each action/command) + 2) Expected result + 3) Actual result + 4) Variations tried (include only if the user explicitly provided them) + + ## Constraints/Preferences + [Performance, accessibility, UX, or other considerations] + ``` + + Rules: + - Keep non-technical; do NOT include code paths, line numbers, stack traces, or diffs. + - Ground the wording in verified behavior, but keep implementation details internal. + - Sourcing: Do not infer or fabricate reproduction details or “Variations tried.” Include them only if explicitly provided by the user; otherwise omit the line. + - Quoting fidelity: If the user lists “Variations tried,” include them faithfully (verbatim or clearly paraphrased without adding new items). + - Value framing: Ensure the “Problem / Value” explains why it matters (impact on users or outcomes) in plain language. + - Title: Produce a concise Title (≤ 80 chars) prefixed with [BUG] or [ENHANCEMENT]; when helpful, append a brief value phrase in parentheses, e.g., “(blocks new runs)”. + + Iteration note: + - If new info arrives after drafting, loop back to Step 4, then update this draft accordingly. - How would you like to proceed? - - Submit the issue now to the repository - Let me make some edits to the issue first - I'll submit it manually later - - - - Based on the user's response: - - If "Submit the issue now": - - Use gh issue create with the saved file - - Provide the created issue URL and number - - Clean up the temporary file - - Complete the workflow - - If "Let me make some edits": - - Ask what changes they'd like to make - - Update the draft file with their changes - - Return to the submission question - - If "I'll submit it manually": - - Inform them the draft is saved at the configured location - - Provide the gh command they can use later - - Complete the workflow without submission - - Update todos based on the outcome: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [x] Draft issue content - [x] Review and confirm with user - [x] Prepare issue for submission - [-] Handle submission choice - - - - + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [x] Perform targeted codebase discovery (iteration N) + [x] Clarify missing details (repro or desired outcome) + [x] Classify type (Bug | Enhancement) + [-] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + - - Handle Submission Choice - - This step handles the user's choice from step 9. - - OPTION 1: Submit the issue now - If the user chooses to submit immediately: - - - gh issue create --repo $REPO_FULL_NAME --title "[Descriptive title]" --body-file ./github_issue_draft.md --label "[appropriate labels]" - - - Label selection based on findings: - - Bug: Use "bug" label - - Feature: Use "enhancement" label - - If affects multiple packages in monorepo: add "affects-multiple" label - - After successful creation: - - Capture and display the issue URL - - Clean up the temporary file: - - rm ./github_issue_draft.md - - - Provide a summary of key findings included - - OPTION 2: Make edits - If the user wants to edit: - - - What changes would you like to make to the issue? - - Update the title - Modify the problem description - Add or remove technical details - Change the labels or other metadata - - - - - Apply the requested changes to the draft - - Update the file with write_to_file - - Return to step 9 to ask about submission again - - OPTION 3: Manual submission - If the user will submit manually: - - Provide clear instructions: - "The issue draft has been saved to ./github_issue_draft.md + + Review and Submit (Single-Step) + + Present the full current issue details in a code block. Offer two submission options; any other response is treated as a change request. + + + Review the current issue details. Select one of the options below or specify any changes or other workflow you would like me to perform: + +```md +Title: [ISSUE_TITLE] + +[ISSUE_BODY] +``` + + Submit now + Submit now and assign to me + + + + Responses: + - If "Submit now": + Prepare: + - Title: derive from Summary (≤ 80 chars, plain language) + - Body: the finalized issue body + + Execute: + + gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")" + + + - If "Submit now and assign to me": + Execute (assignment at creation; falls back to edit if needed): + + ISSUE_URL=$(gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")" --assignee "@me") || true; if [ -z "$ISSUE_URL" ]; then ISSUE_URL=$(gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")"); gh issue edit "$ISSUE_URL" --add-assignee "@me"; fi; echo "$ISSUE_URL" + + + - Any other response: + - Collect requested edits and apply them + - Loop back to Step 4 (Discovery) if new information affects context + - Re-assemble in Step 7 + - Rerun this step and present the updated issue details + + On success: Capture the created issue URL from stdout and complete: + + + Created issue: [URL] + + + + On failure: Present the error succinctly and offer to retry after fixing gh setup (installation/auth). Provide the computed Title and Body inline so the user can submit manually if needed. - To submit it later, you can use: - gh issue create --repo $REPO_FULL_NAME --title "[Your title]" --body-file ./github_issue_draft.md --label "[labels]" - - Or you can copy the content and create the issue through the GitHub web interface." - - Final todo update: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [x] Draft issue content - [x] Review and confirm with user - [x] Prepare issue for submission - [x] Handle submission choice - - - - + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [x] Perform targeted codebase discovery (iteration N) + [x] Clarify missing details (repro or desired outcome) + [x] Classify type (Bug | Enhancement) + [x] Assemble Issue Body + [x] Review and submit (Submit now | Submit now and assign to me) + + + + + + + + Repository detection (git repo present and origin remote configured) is performed before any submission. + Issue is submitted via gh after choosing "Submit now" or "Submit now and assign to me", and the created issue URL is returned. + When "Submit now and assign to me" is chosen, the issue is assigned to the current GitHub user using --assignee "@me" (or gh issue edit fallback). + Submission uses Title and Body only and specifies --repo [OWNER_REPO] discovered in Step 2; no temporary files or file paths are used. + Language is plain and user-centric; no technical artifacts included in the issue body. + Content grounded by repeated codebase exploration cycles as needed. + Early-stop/escalate-once applied per iteration; unlimited iterations across the conversation. + The merged step offers "Submit now" or "Submit now and assign to me"; any other response is treated as a change request and the step is shown again with the full current issue details. + \ No newline at end of file diff --git a/.roo/rules-issue-writer/2_github_issue_templates.xml b/.roo/rules-issue-writer/2_github_issue_templates.xml deleted file mode 100644 index 36b44125dd1..00000000000 --- a/.roo/rules-issue-writer/2_github_issue_templates.xml +++ /dev/null @@ -1,190 +0,0 @@ - - - This mode prioritizes using repository-specific issue templates over hardcoded ones. - If no templates exist in the repository, simple generic templates are created on the fly. - - - - - .github/ISSUE_TEMPLATE/*.yml - .github/ISSUE_TEMPLATE/*.yaml - .github/ISSUE_TEMPLATE/*.md - .github/issue_template.md - .github/ISSUE_TEMPLATE.md - - - - Display name of the template - Brief description of when to use this template - Default issue title (optional) - Array of labels to apply - Array of default assignees - Array of form elements or markdown content - - - - - Static markdown content - - The markdown content to display - - - - - Single-line text input - - Unique identifier - Display label - Help text - Placeholder text - Default value - Boolean - - - - - Multi-line text input - - Unique identifier - Display label - Help text - Placeholder text - Default value - Boolean - Language for syntax highlighting - - - - - Dropdown selection - - Unique identifier - Display label - Help text - Array of options - Boolean - - - - - Multiple checkbox options - - Unique identifier - Display label - Help text - Array of checkbox items - - - - - - - Optional YAML front matter with: - - name: Template name - - about: Template description - - title: Default title - - labels: Comma-separated or array - - assignees: Comma-separated or array - - - Markdown content with sections and placeholders - Common patterns: - - Headers with ## - - Placeholder text in brackets or as comments - - Checklists with - [ ] - - Code blocks with ``` - - - - - - - When no repository templates exist, create simple templates based on issue type. - These should be minimal and focused on gathering essential information. - - - - - - Description: Clear explanation of the bug - - Steps to Reproduce: Numbered list - - Expected Behavior: What should happen - - Actual Behavior: What actually happens - - Additional Context: Version, environment, logs - - Code Investigation: Findings from exploration (if any) - - ["bug"] - - - - - - Problem Description: What problem this solves - - Current Behavior: How it works now - - Proposed Solution: What should change - - Impact: Who benefits and how - - Technical Context: Code findings (if any) - - ["enhancement", "proposal"] - - - - - - When parsing YAML templates: - 1. Use a YAML parser to extract the structure - 2. Convert form elements to markdown sections - 3. Preserve required field indicators - 4. Include descriptions as help text - 5. Maintain the intended flow of the template - - - - When parsing Markdown templates: - 1. Extract front matter if present - 2. Identify section headers - 3. Look for placeholder patterns - 4. Preserve formatting and structure - 5. Replace generic placeholders with user's information - - - - For template selection: - 1. If only one template exists, use it automatically - 2. If multiple exist, let user choose based on name/description - 3. Match template to issue type when possible (bug vs feature) - 4. Respect template metadata (labels, assignees, etc.) - - - - - - Fill templates intelligently using gathered information: - - Map user's description to appropriate sections - - Include code investigation findings where relevant - - Preserve template structure and formatting - - Don't leave placeholder text unfilled - - Add contributor scoping if user is contributing - - - - - - - - - - - - - When no templates exist, create appropriate generic templates on the fly. - Keep them simple and focused on essential information. - - - - - Don't overwhelm with too many fields - - Focus on problem description first - - Include technical details only if user is contributing - - Use clear, simple section headers - - Adapt based on issue type (bug vs feature) - - - \ No newline at end of file diff --git a/.roo/rules-issue-writer/3_best_practices.xml b/.roo/rules-issue-writer/3_best_practices.xml index f2f149ed262..b6f90c8014b 100644 --- a/.roo/rules-issue-writer/3_best_practices.xml +++ b/.roo/rules-issue-writer/3_best_practices.xml @@ -1,172 +1,147 @@ + + This mode assembles a template-free issue body grounded by codebase exploration and can submit it via GitHub CLI after explicit confirmation. + Submission uses Title and Body only and targets the detected repository after the merged Review and Submit step. + + - - CRITICAL: This mode assumes the user's FIRST message is already an issue description - - Do NOT ask "What would you like to do?" or "Do you want to create an issue?" - - Immediately start the issue creation workflow when the user begins talking - - Treat their initial message as the problem/feature description - - Begin with repository detection and codebase discovery right away - - The user is already in "issue creation mode" by choosing this mode + - Treat the user's FIRST message as the issue description; do not ask if they want to create an issue. + - Start with repository detection (verify git repo; resolve OWNER/REPO from origin), then determine repository structure (monorepo/standard). + - After detection, begin codebase discovery scoped to the repository root or the selected package (in monorepos). + - Keep final output non-technical; implementation details remain internal. - - - - ALWAYS check for repository-specific issue templates before creating issues - - Use templates from .github/ISSUE_TEMPLATE/ directory if they exist - - Parse both YAML (.yml/.yaml) and Markdown (.md) template formats - - If multiple templates exist, let the user choose the appropriate one - - If no templates exist, create a simple generic template on the fly - - NEVER fall back to hardcoded templates - always use repo templates or generate minimal ones - - Respect template metadata like labels, assignees, and title patterns - - Fill templates intelligently using gathered information from codebase exploration - - - - - Focus on helping users describe problems clearly, not solutions - - The project team will design solutions unless the user explicitly wants to contribute - - Don't push users to provide technical details they may not have - - Make it easy for non-technical users to report issues effectively - - CRITICAL: Lead with user impact: - - Always explain WHO is affected and WHEN the problem occurs - - Use concrete examples with actual values, not abstractions - - Show before/after scenarios with specific data - - Example: "Users trying to [action] see [actual result] instead of [expected result]" - - - - - ALWAYS verify user claims against actual code implementation - - For feature requests, aggressively check if current behavior matches user's description - - If code shows different intent than user describes, it might be a bug not a feature - - Present code evidence when challenging user assumptions - - Do not be agreeable - be fact-driven and question discrepancies - - Continue verification until facts are established - - A "feature request" where code shows the feature should already work is likely a bug - - CRITICAL additions for thorough analysis: - - Trace data flow from where values are created to where they're used - - Look for existing variables/functions that already contain needed data - - Check if the issue is just missing usage of existing code - - Follow imports and exports to understand data availability - - Identify patterns in similar features that work correctly - - - - - Always search for existing similar issues before creating a new one - - Check for and use repository issue templates before creating content - - Include specific version numbers and environment details - - Use code blocks with syntax highlighting for code snippets - - Make titles descriptive but concise (e.g., "Dark theme: Submit button invisible due to white-on-grey text") - - For bugs, always test if the issue is reproducible - - Include screenshots or mockups when relevant (ask user to provide) - - Link to related issues or PRs if found during exploration - - CRITICAL: Use concrete examples throughout: - - Show actual data values, not just descriptions - - Include specific file paths and line numbers - - Demonstrate the data flow with real examples - - Bad: "The value is incorrect" - - Good: "The function returns '123' when it should return '456'" - - - - - Only perform issue scoping if user wants to contribute - - Reference specific files and line numbers from codebase exploration - - Ensure technical proposals align with project architecture - - Include implementation steps and issue scoping - - Provide clear acceptance criteria in Given/When/Then format - - Consider trade-offs and alternative approaches - - CRITICAL: Prioritize simple solutions: - - ALWAYS check if needed functionality already exists before proposing new code - - Look for existing variables that just need to be passed/used differently - - Prefer using existing patterns over creating new ones - - The best fix often involves minimal code changes - - Example: "Use existing `modeInfo` from line 234 in export" vs "Create new mode tracking system" - - - - ALWAYS consider backwards compatibility: - - Think about existing data/configurations already in use - - Propose solutions that handle both old and new formats gracefully - - Consider migration paths for existing users - - Document any breaking changes clearly - - Prefer additive changes over breaking changes when possible - - + + + + - Always pair the problem with user-facing value: who is impacted, when it occurs, and why it matters. + - Keep value non-technical (clarity, time saved, fewer errors, better UX, improved accessibility, reduced confusion). + + + - Severity: Blocker | High | Medium | Low (optional) + - Reach: Few | Some | Many (optional) + + + + + + - Reproduction steps + - Variations tried + - Environment details + + + - Problem/Value statement (plain-language synthesis from user wording) + - Context (who/when) based on user input; keep code-based signals internal + + + - Never fabricate “Variations tried.” If not provided, omit. + - If critical details are missing, ask targeted questions; otherwise proceed with omissions. + + + + + + Use a single merged "Review and Submit" step with options: + - Submit now + - Submit now and assign to me + Any other response is treated as a change request and the step is rerun after applying edits. + + + Submission requires repository detection (git present, origin configured). Capture normalized OWNER/REPO (e.g., owner/repo) and store as [OWNER_REPO] for submission. + + + Always specify the target using --repo "[OWNER_REPO]" to avoid ambiguity and ensure the correct repository is used. + + + When "Submit now and assign to me" is chosen, create using: --assignee "@me". + If creation with --assignee fails (e.g., permissions), create the issue without an assignee and immediately run: + gh issue edit --add-assignee "@me". + + + Use --body with robust quoting (for example: --body "$(printf '%s\n' "[ISSUE_BODY]")") or a heredoc; do not create temporary files or reference file paths. Always include --repo "[OWNER_REPO]" and echo the resulting issue URL. + In execute_command calls, output only the command string; never include XML tags, CDATA markers, code fences, or backticks in the command payload. + + + On gh errors (installation/auth), present the error and offer to retry after fixing gh setup. Surface the computed Title and Body inline + so the user can submit manually if needed. + + + + + + - Use semantic search first to find relevant areas. + - Refine with targeted regex for exact strings (errors, component names, flags). + - Read key files to verify behavior; keep evidence internal. + - Early-stop when hits converge (~70%) or you can name the exact feature/component. + - Escalate-once if signals conflict; run one refined batch, then proceed. + + + 1) codebase_search → 2) search_files → 3) read_file (as needed) + + + In monorepos, scope searches to the selected package when the context is clear; otherwise ask for the relevant package/app if ambiguous. + + + Keep language plain and exclude technical artifacts (paths, line numbers, stack traces, diffs) from the final issue body. + + + + + + - Ask minimal, targeted questions based on what you found in code. + - For bugs: request a minimal reproduction (environment, steps, expected, actual, variations). + - For enhancements: capture user goal, desired behavior in plain language, and any constraints. + - Present discrepancies in plain language (no code) and confirm understanding. + + + + + + + + + - Omit sections that would be empty. + - Do not include "Variations tried" unless explicitly provided by the user. + - Keep language plain and user-centric. + - Exclude technical artifacts (paths, lines, stacks, diffs). + + + + + - At each review stage, present the full current issue details (Title + Body) in a markdown code block. + - Offer "Submit now" or "Submit now and assign to me" suggestions; treat any other response as a change request and rerun the step after applying edits. + + + + - Tool preambles: restate goal briefly, outline a short plan, narrate progress succinctly, summarize final delta. + - One-tool-per-message: await results before continuing. + - Discovery budget: default max 3 searches before escalate-once; stop when sufficient. + - Early-stop: when top hits converge or target is identifiable. + - Verbosity: low narrative; detail appears only in structured outputs. + + - - Be supportive and encouraging to problem reporters - - Don't overwhelm users with technical questions upfront - - Clearly indicate when technical sections are optional - - Guide contributors through the additional requirements - - Make the "submit now" option clear for problem reporters - - When presenting template choices, include template descriptions to help users choose - - Explain that you're using the repository's own templates for consistency + - Be direct and concise; avoid jargon in the final issue body. + - Keep questions optional and easy to answer with suggested options. + - Emphasize WHO is affected and WHEN it happens. - - - - Always check these locations in order: - 1. .github/ISSUE_TEMPLATE/*.yml or *.yaml (GitHub form syntax) - 2. .github/ISSUE_TEMPLATE/*.md (Markdown templates) - 3. .github/issue_template.md (single template) - 4. .github/ISSUE_TEMPLATE.md (alternate naming) - - - - For YAML templates: - - Extract form elements and convert to appropriate markdown sections - - Preserve required field indicators - - Include field descriptions as context - - Respect dropdown options and checkbox lists - - For Markdown templates: - - Parse front matter for metadata - - Identify section headers and structure - - Replace placeholder text with actual information - - Maintain formatting and hierarchy - - - - - Map gathered information to template sections intelligently - - Don't leave placeholder text in the final issue - - Add code investigation findings to relevant sections - - Include contributor scoping in appropriate section if applicable - - Preserve the template's intended structure and flow - - - - When no templates exist: - - Create minimal, focused templates - - Use simple section headers - - Focus on essential information only - - Adapt structure based on issue type - - Don't overwhelm with unnecessary fields - - - - - Before proposing ANY solution: - 1. Use codebase_search extensively to find all related code - 2. Read multiple files to understand the full context - 3. Trace variable usage from creation to consumption - 4. Look for similar working features to understand patterns - 5. Identify what already exists vs what's actually missing - - - - When designing solutions: - 1. Check if the data/function already exists somewhere - 2. Look for configuration options before code changes - 3. Prefer passing existing variables over creating new ones - 4. Use established patterns from similar features - 5. Aim for minimal diff size - - - - Always include: - - Exact file paths and line numbers - - Variable/function names as they appear in code - - Before/after code snippets showing minimal changes - - Clear explanation of why the simple fix works - - \ No newline at end of file diff --git a/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml b/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml index a8dd9b590b0..4077edfb4d3 100644 --- a/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml +++ b/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml @@ -1,126 +1,109 @@ - - CRITICAL: Asking "What would you like to do?" when mode starts - - Waiting for user to say "create an issue" or "make me an issue" - - Not treating the first user message as the issue description - - Delaying the workflow start with unnecessary questions - - Asking if they want to create an issue when they've already chosen this mode - - Not immediately beginning repository detection and codebase discovery + - Asking "What would you like to do?" at start instead of treating the first message as the issue description + - Delaying the workflow with unnecessary questions before discovery + - Not immediately beginning codebase-aware discovery (semantic search → regex refine → read key files) + - Skipping repository detection (git + origin) before discovery or submission + - Not validating repository context before gh commands - + + + - Submitting without explicit user confirmation ("Submit now") + - Targeting the wrong repository by relying on current directory defaults; always pass --repo OWNER/REPO detected in Step 2 + - Performing PR prep, complexity estimates, or technical scoping + + + + + Splitting final review and submission into multiple steps + Creates redundant prompts and inconsistent state; leads to janky UX + Use a single merged "Review and Submit" step offering only: Submit now, Submit now and assign to me; treat any other response as a change request + + + Not offering "Submit now and assign to me" + Forces manual assignment later; reduces efficiency + Provide the assignment option and use gh issue create --assignee "@me"; if that fails, immediately run gh issue edit --add-assignee "@me" + + + Using temporary files or --body-file for issue body submission + Introduces filesystem dependencies and leaks paths; contradicts single-command policy + Use inline --body with robust quoting, e.g., --body "$(printf '%s\n' "[ISSUE_BODY]")"; do not reference any file paths + + + Omitting --repo or relying on current directory defaults + May submit to the wrong repository in multi-repo or worktree contexts + Always pass --repo [OWNER_REPO] detected in Step 2 + + + Attempting submission without prior repository detection + Commands may target the wrong repo or fail + Detect git repo and ensure origin is configured before any gh commands + + + + + + Inventing or inferring “Variations tried” when the user didn’t provide any + Misleads triage and wastes time reproducing non-existent attempts + Omit the “Variations tried” line entirely unless explicitly provided; if needed, ask a targeted question first + + + Framing only the problem without the value/impact + Makes prioritization harder; obscures who benefits and why it matters + Pair the problem with a plain-language value statement (who, when, why it matters) + + + Overstating impact without user signal + Damages credibility and misguides prioritization + Use conservative, plain language; if unsure, omit severity/reach or ask a single targeted question + + + - - Vague descriptions like "doesn't work" or "broken" - - Missing reproduction steps for bugs - - Feature requests without clear problem statements - - Not explaining the impact on users - - Forgetting to specify when/how the problem occurs - - Using wrong labels or no labels - - Titles that don't summarize the issue - - Not checking for duplicates + - Vague descriptions like "doesn't work" without who/when impact + - Missing minimal reproduction for bugs (environment, steps, expected, actual, variations) + - Enhancement requests that skip the user goal or desired behavior in plain language + - Titles/summaries that don't quickly communicate the issue - - - - Asking for technical details from non-contributing users - - Performing issue scoping before confirming user wants to contribute - - Requiring acceptance criteria from problem reporters - - Making the process too complex for simple problem reports - - Not clearly indicating the "submit now" option - - Overwhelming users with contributor requirements upfront - - Using hardcoded templates instead of repository templates - - Not checking for issue templates before creating content - - Ignoring template metadata like labels and assignees - - - - - Starting implementation before approval - - Not providing detailed issue scoping when contributing - - Missing acceptance criteria for contributed features - - Forgetting to include technical context from code exploration - - Not considering trade-offs and alternatives - - Proposing solutions without understanding current architecture - - - - Not tracing data flow completely through the system - Missing that data already exists leads to proposing unnecessary new code + + + - Including code paths, line numbers, stack traces, or diffs in the final issue body + - Adding labels, metadata, or repository details to the body + - Leaving empty section placeholders instead of omitting the section + - Using technical jargon instead of plain, user-centric language + + + + Skipping semantic search and jumping straight to assumptions + Leads to misclassification and inaccurate context - - Use codebase_search extensively to find ALL related code - - Trace variables from creation to consumption - - Check if needed data is already calculated but not used - - Look for similar working features as patterns + - Start with codebase_search on extracted keywords + - Refine with search_files for exact strings (errors, component names, flags) + - read_file only as needed to verify behavior; keep evidence internal + - Early-stop when hits converge or you can name the exact feature/component + - Escalate-once if signals conflict (one refined pass), then proceed - - Bad: "Add mode tracking to import function" - Good: "The export already includes mode info at line 234, just use it in import at line 567" - - - - - Proposing complex new systems when simple fixes exist - Creates unnecessary complexity, maintenance burden, and potential bugs + + + + Accepting user claims that contradict the codebase without verification + Produces misleading or incorrect issue framing - - ALWAYS check if functionality already exists first - - Look for minimal changes that solve the problem - - Prefer using existing variables/functions differently - - Aim for the smallest possible diff + - Verify claims against the implementation; trace data from creation → usage + - Compare with similar working features to ground expectations + - If discrepancies arise, present concrete, plain-language examples (no code) and confirm - - Bad: "Create new state management system for mode tracking" - Good: "Pass existing modeInfo variable from line 45 to the function at line 78" - - - - - Not reading actual code before proposing solutions - Solutions don't match the actual codebase structure - - - Always read the relevant files first - - Verify exact line numbers and content - - Check imports/exports to understand data availability - - Look at similar features that work correctly - - - - - Creating new patterns instead of following existing ones - Inconsistent codebase, harder to maintain - - - Find similar features that work correctly - - Follow the same patterns and structures - - Reuse existing utilities and helpers - - Maintain consistency with the codebase style - - - - - Using hardcoded templates when repository templates exist - Issues don't follow repository conventions, may be rejected or need reformatting - - - Always check .github/ISSUE_TEMPLATE/ directory first - - Parse and use repository templates when available - - Only create generic templates when none exist - - - - - Not properly parsing YAML template structure - Missing required fields, incorrect formatting, lost metadata - - - Parse YAML templates to extract all form elements - - Convert form elements to appropriate markdown sections - - Preserve field requirements and descriptions - - Maintain dropdown options and checkbox lists - - - - - Leaving placeholder text in final issue - Unprofessional appearance, confusion about what information is needed - - - Replace all placeholders with actual information - - Remove instruction text meant for template users - - Fill every section with relevant content - - Add "N/A" for truly inapplicable sections - - + + + + - Asking broad, unfocused questions instead of targeted ones based on findings + - Demanding technical details from non-technical users + - Failing to provide easy, suggested answer formats (repro scaffold, goal statement) + + + + - Mixing internal technical evidence into the final body + - Ignoring the issue format or adding extra sections + - Using inconsistent tone or switching between technical and non-technical language + \ No newline at end of file diff --git a/.roo/rules-issue-writer/5_examples.xml b/.roo/rules-issue-writer/5_examples.xml new file mode 100644 index 00000000000..6c19018e6c2 --- /dev/null +++ b/.roo/rules-issue-writer/5_examples.xml @@ -0,0 +1,134 @@ + + + Examples of assembling template-free issue prompts grounded by codebase exploration, with optional CLI submission after explicit confirmation. + Repository detection precedes submission; review and submission occur in a single merged step offering "Submit now" or "Submit now and assign to me". Any other response is treated as a change request. + + + + + In dark theme the Submit button is almost invisible on the New Run page. + + + + +dark theme submit button visibility + + + +. +Submit|button|dark|theme + + ]]> + + + Internal: matches found in UI components related to theme; wording grounded to user impact. + + + Scroll to bottom -> Look for Submit +2) Expected result: Clearly visible, high-contrast Submit button +3) Actual result: Button appears nearly invisible in dark theme +4) Variations tried: Different browsers (Chrome/Firefox) show same result + ]]> + + + + + I accidentally click "Copy Run" sometimes; would be great to have a simple confirmation. + + + + +Copy Run confirmation + + ]]> + + + Internal: feature entry point identified; keep final output non-technical and user-centric. + + + + + + + + Dark theme Submit button is invisible; I'd like to file this. + + Scroll to bottom -> Look for Submit +2) Expected result: Clearly visible, high-contrast Submit button +3) Actual result: Button appears nearly invisible in dark theme + ]]> + + + Review the current issue details. Select one of the options below or specify any changes or other workflow you would like me to perform: + +```md +Title: [ISSUE_TITLE] + +[ISSUE_BODY] +``` + + Submit now + Submit now and assign to me + + + + + gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")" + + + + ISSUE_URL=$(gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")" --assignee "@me") || true; if [ -z "$ISSUE_URL" ]; then ISSUE_URL=$(gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")"); gh issue edit "$ISSUE_URL" --add-assignee "@me"; fi; echo "$ISSUE_URL" + + + + If a change request is provided, collect the requested edits, update the draft (re-run discovery if new info affects context), then rerun this merged step. + + + https://github.com/OWNER/REPO/issues/123 + + + + + Issues are template-free (Title + Body only). + Repository detection (git + origin → OWNER/REPO) occurs before submission and is passed explicitly via --repo [OWNER_REPO]. + Never use --body-file or temporary files; submit with inline --body only (no file paths). + Review and submission happen in one merged step offering "Submit now" or "Submit now and assign to me"; any other response is treated as a change request. + All discovery is internal; keep final output plain-language. + + \ No newline at end of file diff --git a/.roo/rules-issue-writer/5_github_cli_usage.xml b/.roo/rules-issue-writer/5_github_cli_usage.xml deleted file mode 100644 index 1792be87ebb..00000000000 --- a/.roo/rules-issue-writer/5_github_cli_usage.xml +++ /dev/null @@ -1,342 +0,0 @@ - - - The GitHub CLI (gh) provides comprehensive tools for interacting with GitHub. - Here's when and how to use each command in the issue creation workflow. - - Note: This mode prioritizes using repository-specific issue templates over - hardcoded ones. Templates are detected and used dynamically from the repository. - - - - - - ALWAYS use this FIRST before creating any issue to check for duplicates. - Search for keywords from the user's problem description. - - - - gh issue list --repo $REPO_FULL_NAME --search "dark theme button visibility" --state all --limit 20 - - - - --search: Search query for issue titles and bodies - --state: all, open, or closed - --label: Filter by specific labels - --limit: Number of results to show - --json: Get structured JSON output - - - - - - Use for more advanced searches across issues and pull requests. - Supports GitHub's advanced search syntax. - - - - gh search issues --repo $REPO_FULL_NAME "dark theme button" --limit 10 - - - - - - - Use when you find a potentially related issue and need full details. - Check if the user's issue is already reported or related. - - - - gh issue view 123 --repo $REPO_FULL_NAME --comments - - - - --comments: Include issue comments - --json: Get structured data - --web: Open in browser - - - - - - - - Use to check for issue templates in the repository before creating issues. - This is not a gh command but necessary for template detection. - - - Check for templates in standard location: - - .github/ISSUE_TEMPLATE - true - - - Check for single template file: - - .github - false - - - - - - - Read template files to parse their structure and content. - Used after detecting template files. - - - Read YAML template: - - .github/ISSUE_TEMPLATE/bug_report.yml - - - Read Markdown template: - - .github/ISSUE_TEMPLATE/feature_request.md - - - - - - - - These commands should ONLY be used if the user has indicated they want to - contribute the implementation. Skip these for problem reporters. - - - - - Get repository information and recent activity. - - - - gh repo view $REPO_FULL_NAME --json defaultBranchRef,description,updatedAt - - - - - - - Check recent PRs that might be related to the issue. - Look for PRs that modified relevant code. - - - - gh search prs --repo $REPO_FULL_NAME "dark theme" --limit 10 --state all - - - - - - - For bug reports from contributors, check recent commits that might have introduced the issue. - Use after cloning the repository locally. - - - - git log --oneline --grep="theme" -n 20 - - - - - - - - - Only use after: - 1. Confirming no duplicates exist - 2. Checking for and using repository templates - 3. Gathering all required information - 4. Determining if user is contributing or just reporting - 5. Getting user confirmation - - - - gh issue create --repo $REPO_FULL_NAME --title "[Descriptive title of the bug]" --body-file /tmp/issue_body.md --label "bug" - - - - - gh issue create --repo $REPO_FULL_NAME --title "[Problem-focused title]" --body-file /tmp/issue_body.md --label "proposal" --label "enhancement" - - - - --title: Issue title (required) - --body: Issue body text - --body-file: Read body from file - --label: Add labels (can use multiple times) - --assignee: Assign to user - --project: Add to project - --web: Open in browser to create - - - - - - - - ONLY use if user wants to add additional information after creation. - - - - gh issue comment 456 --repo $REPO_FULL_NAME --body "Additional context or comments." - - - - - - - Use if user realizes they need to update the issue after creation. - Can update title, body, or labels. - - - - gh issue edit 456 --repo $REPO_FULL_NAME --title "[Updated title]" --body "[Updated body]" - - - - - - - - After user selects issue type, immediately search for related issues: - 1. Use `gh issue list --search` with keywords from their description - 2. Show any similar issues found - 3. Ask if they want to continue or comment on existing issue - - - - Template detection (NEW): - 1. Use list_files to check .github/ISSUE_TEMPLATE/ directory - 2. Read any template files found (YAML or Markdown) - 3. Parse template structure and metadata - 4. If multiple templates, let user choose - 5. If no templates, prepare to create generic one - - - - Decision point for contribution: - 1. Ask user if they want to contribute implementation - 2. If yes: Use contributor commands for codebase investigation - 3. If no: Skip directly to creating a problem-focused issue - 4. This saves time for problem reporters - - - - During codebase exploration (CONTRIBUTORS ONLY): - 1. Clone repo locally if needed: `gh repo clone $REPO_FULL_NAME` - 2. Use `git log` to find recent changes to affected files - 3. Use `gh search prs` for related pull requests - 4. Include findings in the technical context section - - - - When creating the issue: - 1. Use repository template if found, or generic template if not - 2. Fill template with gathered information - 3. Format differently based on contributor vs problem reporter - 4. Save formatted body to temporary file - 5. Use `gh issue create` with appropriate labels from template - 6. Capture the returned issue URL - 7. Show user the created issue URL - - - - - - When creating issues with long bodies: - 1. Save to temporary file: `cat > /tmp/issue_body.md << 'EOF'` - 2. Use --body-file flag with gh issue create - 3. Clean up after: `rm /tmp/issue_body.md` - - - - Use specific search terms: - - Include error messages in quotes - - Use label filters when appropriate - - Limit results to avoid overwhelming output - - - - Use --json flag for structured data when needed: - - Easier to parse programmatically - - Consistent format across commands - - Example: `gh issue list --json number,title,state` - - - - - - If search finds exact duplicate: - - Show the existing issue to user using `gh issue view` - - Ask if they want to add a comment instead - - Use `gh issue comment` if they agree - - - - If `gh issue create` fails: - - Check error message (auth, permissions, network) - - Ensure gh is authenticated: `gh auth status` - - Save the drafted issue content for user - - Suggest using --web flag to create in browser - - - - Ensure GitHub CLI is authenticated: - - Check status: `gh auth status` - - Login if needed: `gh auth login` - - Select appropriate scopes for issue creation - - - - - - gh issue create - Create new issue - gh issue list - List and search issues - gh issue view - View issue details - gh issue comment - Add comment to issue - gh issue edit - Edit existing issue - gh issue close - Close an issue - gh issue reopen - Reopen closed issue - - - - gh search issues - Search issues and PRs - gh search prs - Search pull requests - gh search repos - Search repositories - - - - gh repo view - View repository info - gh repo clone - Clone repository - - - - - - When parsing YAML templates: - - Extract 'name' for template identification - - Get 'labels' array for automatic labeling - - Parse 'body' array for form elements - - Convert form elements to markdown sections - - Preserve 'required' field indicators - - - - When parsing Markdown templates: - - Check for YAML front matter - - Extract metadata (labels, assignees) - - Identify section headers - - Replace placeholder text - - Maintain formatting structure - - - - 1. Detect templates with list_files - 2. Read templates with read_file - 3. Parse structure and metadata - 4. Let user choose if multiple exist - 5. Fill template with information - 6. Create issue with template content - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/1_mode_creation_workflow.xml b/.roo/rules-mode-writer/1_mode_creation_workflow.xml deleted file mode 100644 index 77a1728599f..00000000000 --- a/.roo/rules-mode-writer/1_mode_creation_workflow.xml +++ /dev/null @@ -1,301 +0,0 @@ - - - This workflow guides you through creating new custom modes or editing existing modes - for the Roo Code Software, ensuring comprehensive understanding and cohesive implementation. - - - - - Determine User Intent - - Identify whether the user wants to create a new mode or edit an existing one - - - - - User mentions a specific mode by name or slug - User references a mode directory path (e.g., .roo/rules-[mode-slug]) - User asks to modify, update, enhance, or fix an existing mode - User says "edit this mode" or "change this mode" - - - - - User asks to create a new mode - User describes a new capability not covered by existing modes - User says "make a mode for" or "create a mode that" - - - - - - I want to make sure I understand correctly. Are you looking to create a brand new mode or modify an existing one? - - Create a new mode for a specific purpose - Edit an existing mode to add new capabilities - Fix issues in an existing mode - Enhance an existing mode with better workflows - - - - - - - - - - Gather Requirements for New Mode - - Understand what the user wants the new mode to accomplish - - - Ask about the mode's primary purpose and use cases - Identify what types of tasks the mode should handle - Determine what tools and file access the mode needs - Clarify any special behaviors or restrictions - - - - What is the primary purpose of this new mode? What types of tasks should it handle? - - A mode for writing and maintaining documentation - A mode for database schema design and migrations - A mode for API endpoint development and testing - A mode for performance optimization and profiling - - - - - - - Design Mode Configuration - - Create the mode definition with all required fields - - - - Unique identifier (lowercase, hyphens allowed) - Keep it short and descriptive (e.g., "api-dev", "docs-writer") - - - Display name with optional emoji - Use an emoji that represents the mode's purpose - - - Detailed description of the mode's role and expertise - - Start with "You are Roo Code, a [specialist type]..." - List specific areas of expertise - Mention key technologies or methodologies - - - - Tool groups the mode can access - - - - - - - - - - - - Clear description for the Orchestrator - Explain specific scenarios and task types - - - - Do not include customInstructions in the .roomodes configuration. - All detailed instructions should be placed in XML files within - the .roo/rules-[mode-slug]/ directory instead. - - - - - Implement File Restrictions - - Configure appropriate file access permissions - - - Restrict edit access to specific file types - -groups: - - read - - - edit - - fileRegex: \.(md|txt|rst)$ - description: Documentation files only - - command - - - - Use regex patterns to limit file editing scope - Provide clear descriptions for restrictions - Consider the principle of least privilege - - - - - Create XML Instruction Files - - Design structured instruction files in .roo/rules-[mode-slug]/ - - - Main workflow and step-by-step processes - Guidelines and conventions - Reusable code patterns and examples - Specific tool usage instructions - Complete workflow examples - - - Use semantic tag names that describe content - Nest tags hierarchically for better organization - Include code examples in CDATA sections when needed - Add comments to explain complex sections - - - - - - - Immerse in Existing Mode - - Fully understand the existing mode before making any changes - - - Locate and read the mode configuration in .roomodes - Read all XML instruction files in .roo/rules-[mode-slug]/ - Analyze the mode's current capabilities and limitations - Understand the mode's role in the broader ecosystem - - - - What specific aspects of the mode would you like to change or enhance? - - Add new capabilities or tool permissions - Fix issues with current workflows or instructions - Improve the mode's roleDefinition or whenToUse description - Enhance XML instructions for better clarity - - - - - - - Analyze Change Impact - - Understand how proposed changes will affect the mode - - - Compatibility with existing workflows - Impact on file permissions and tool access - Consistency with mode's core purpose - Integration with other modes - - - - I've analyzed the existing mode. Here's what I understand about your requested changes. Is this correct? - - Yes, that's exactly what I want to change - Mostly correct, but let me clarify some details - No, I meant something different - I'd like to add additional changes - - - - - - - Plan Modifications - - Create a detailed plan for modifying the mode - - - Identify which files need to be modified - Determine if new XML instruction files are needed - Check for potential conflicts or contradictions - Plan the order of changes for minimal disruption - - - - - Implement Changes - - Apply the planned modifications to the mode - - - Update .roomodes configuration if needed - Modify existing XML instruction files - Create new XML instruction files if required - Update examples and documentation - - - - - - - - Validate Cohesion and Consistency - - Ensure all changes are cohesive and don't contradict each other - - - - Mode slug follows naming conventions - File restrictions align with mode purpose - Tool permissions are appropriate - whenToUse clearly differentiates from other modes - - - All XML files follow consistent structure - No contradicting instructions between files - Examples align with stated workflows - Tool usage matches granted permissions - - - Mode integrates well with Orchestrator - Clear boundaries with other modes - Handoff points are well-defined - - - - - I've completed the validation checks. Would you like me to review any specific aspect in more detail? - - Review the file permission patterns - Check for workflow contradictions - Verify integration with other modes - Everything looks good, proceed to testing - - - - - - - Test and Refine - - Verify the mode works as intended - - - Mode appears in the mode list - File restrictions work correctly - Instructions are clear and actionable - Mode integrates well with Orchestrator - All examples are accurate and helpful - Changes don't break existing functionality (for edits) - New capabilities work as expected - - - - - - Create mode in .roomodes for project-specific modes - Create mode in global custom_modes.yaml for system-wide modes - Use list_files to verify .roo folder structure - Test file regex patterns with search_files - Use codebase_search to find existing mode implementations - Read all XML files in a mode directory to understand its structure - Always validate changes for cohesion and consistency - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/2_xml_structuring_best_practices.xml b/.roo/rules-mode-writer/2_xml_structuring_best_practices.xml deleted file mode 100644 index 639f855c0c0..00000000000 --- a/.roo/rules-mode-writer/2_xml_structuring_best_practices.xml +++ /dev/null @@ -1,220 +0,0 @@ - - - XML tags help Claude parse prompts more accurately, leading to higher-quality outputs. - This guide covers best practices for structuring mode instructions using XML. - - - - - Clearly separate different parts of your instructions and ensure well-structured content - - - Reduce errors caused by Claude misinterpreting parts of your instructions - - - Easily find, add, remove, or modify parts of instructions without rewriting everything - - - Having Claude use XML tags in its output makes it easier to extract specific parts of responses - - - - - - Use the same tag names throughout your instructions - - Always use for workflow steps, not sometimes or - - - - - Tag names should clearly describe their content - - detailed_steps - error_handling - validation_rules - - - stuff - misc - data1 - - - - - Nest tags to show relationships and structure - - - - Gather requirements - Validate inputs - - - Process data - Generate output - - - - - - - - - For step-by-step processes - - - - - For providing code examples and demonstrations - - - - - For rules and best practices - - - - - For documenting how to use specific tools - - - - - - - Use consistent indentation (2 or 4 spaces) for nested elements - - - Add line breaks between major sections for readability - - - Use XML comments to explain complex sections - - - Use CDATA for code blocks or content with special characters: - ]]> - - - Use attributes for metadata, elements for content: - - - The actual step content - - - - - - - - Avoid completely flat structures without hierarchy - -Do this -Then this -Finally this - - ]]> - - - Do this - Then this - Finally this - - - ]]> - - - - Don't mix naming conventions - - Mixing camelCase, snake_case, and kebab-case in tag names - - - Pick one convention (preferably snake_case for XML) and stick to it - - - - - Avoid tags that don't convey meaning - data, info, stuff, thing, item - user_input, validation_result, error_message, configuration - - - - - - Reference XML content in instructions: - "Using the workflow defined in <workflow> tags..." - - - Combine XML structure with other techniques like multishot prompting - - - Use XML tags in expected outputs to make parsing easier - - - Create reusable XML templates for common patterns - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/3_mode_configuration_patterns.xml b/.roo/rules-mode-writer/3_mode_configuration_patterns.xml deleted file mode 100644 index 82a5f845ac4..00000000000 --- a/.roo/rules-mode-writer/3_mode_configuration_patterns.xml +++ /dev/null @@ -1,261 +0,0 @@ - - - Common patterns and templates for creating different types of modes, with examples from existing modes in the Roo-Code software. - - - - - - Modes focused on specific technical domains or tasks - - - Deep expertise in a particular area - Restricted file access based on domain - Specialized tool usage patterns - - - - You are Roo Code, an API development specialist with expertise in: - - RESTful API design and implementation - - GraphQL schema design - - API documentation with OpenAPI/Swagger - - Authentication and authorization patterns - - Rate limiting and caching strategies - - API versioning and deprecation - - You ensure APIs are: - - Well-documented and discoverable - - Following REST principles or GraphQL best practices - - Secure and performant - - Properly versioned and maintainable - whenToUse: >- - Use this mode when designing, implementing, or refactoring APIs. - This includes creating new endpoints, updating API documentation, - implementing authentication, or optimizing API performance. - groups: - - read - - - edit - - fileRegex: (api/.*\.(ts|js)|.*\.openapi\.yaml|.*\.graphql|docs/api/.*)$ - description: API implementation files, OpenAPI specs, and API documentation - - command - - mcp - ]]> - - - - - Modes that guide users through multi-step processes - - - Step-by-step workflow guidance - Heavy use of ask_followup_question - Process validation at each step - - - - You are Roo Code, a migration specialist who guides users through - complex migration processes: - - Database schema migrations - - Framework version upgrades - - API version migrations - - Dependency updates - - Breaking change resolutions - - You provide: - - Step-by-step migration plans - - Automated migration scripts - - Rollback strategies - - Testing approaches for migrations - whenToUse: >- - Use this mode when performing any kind of migration or upgrade. - This mode will analyze the current state, plan the migration, - and guide you through each step with validation. - groups: - - read - - edit - - command - ]]> - - - - - Modes focused on code analysis and reporting - - - Read-heavy operations - Limited or no edit permissions - Comprehensive reporting outputs - - - - You are Roo Code, a security analysis specialist focused on: - - Identifying security vulnerabilities - - Analyzing authentication and authorization - - Reviewing data validation and sanitization - - Checking for common security anti-patterns - - Evaluating dependency vulnerabilities - - Assessing API security - - You provide detailed security reports with: - - Vulnerability severity ratings - - Specific remediation steps - - Security best practice recommendations - whenToUse: >- - Use this mode to perform security audits on codebases. - This mode will analyze code for vulnerabilities, check - dependencies, and provide actionable security recommendations. - groups: - - read - - command - - - edit - - fileRegex: (SECURITY\.md|\.github/security/.*|docs/security/.*)$ - description: Security documentation files only - ]]> - - - - - Modes for generating new content or features - - - Broad file creation permissions - Template and boilerplate generation - Interactive design process - - - - You are Roo Code, a UI component design specialist who creates: - - Reusable React/Vue/Angular components - - Component documentation and examples - - Storybook stories - - Unit tests for components - - Accessibility-compliant interfaces - - You follow design system principles and ensure components are: - - Highly reusable and composable - - Well-documented with examples - - Fully tested - - Accessible (WCAG compliant) - - Performance optimized - whenToUse: >- - Use this mode when creating new UI components or refactoring - existing ones. This mode helps design component APIs, implement - the components, and create comprehensive documentation. - groups: - - read - - - edit - - fileRegex: (components/.*|stories/.*|__tests__/.*\.test\.(tsx?|jsx?))$ - description: Component files, stories, and component tests - - browser - - command - ]]> - - - - - - For modes that only work with documentation - - - - - For modes that work with test files - - - - - For modes that manage configuration - - - - - For modes that need broad access - - - - - - - Use lowercase with hyphens - api-dev, test-writer, docs-manager - apiDev, test_writer, DocsManager - - - - Use title case with descriptive emoji - 🔧 API Developer, 📝 Documentation Writer - api developer, DOCUMENTATION WRITER - - - - - 🧪 - 📝 - 🎨 - 🪲 - 🏗️ - 🔒 - 🔌 - 🗄️ - - ⚙️ - - - - - - - Ensure whenToUse is clear for Orchestrator mode - - Specify concrete task types the mode handles - Include trigger keywords or phrases - Differentiate from similar modes - Mention specific file types or areas - - - - - Define clear boundaries between modes - - Avoid overlapping responsibilities - Make handoff points explicit - Use switch_mode when appropriate - Document mode interactions - - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/4_instruction_file_templates.xml b/.roo/rules-mode-writer/4_instruction_file_templates.xml deleted file mode 100644 index 3afcfa28f45..00000000000 --- a/.roo/rules-mode-writer/4_instruction_file_templates.xml +++ /dev/null @@ -1,367 +0,0 @@ - - - Templates and examples for creating XML instruction files that provide - detailed guidance for each mode's behavior and workflows. - - - - Number files to indicate execution order - Use descriptive names that indicate content - Keep related instructions together - - 1_workflow.xml - Main workflow and processes - 2_best_practices.xml - Guidelines and conventions - 3_common_patterns.xml - Reusable code patterns - 4_tool_usage.xml - Specific tool instructions - 5_examples.xml - Complete workflow examples - 6_error_handling.xml - Error scenarios and recovery - 7_communication.xml - User interaction guidelines - - - - - Template for main workflow files (1_workflow.xml) - - - - - Template for best practices files (2_best_practices.xml) - - - - - Template for tool usage files (4_tool_usage.xml) - - - - - Template for example files (5_examples.xml) - - - - - Template for communication guidelines (7_communication.xml) - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/5_complete_mode_examples.xml b/.roo/rules-mode-writer/5_complete_mode_examples.xml deleted file mode 100644 index 7c8f8a4cdef..00000000000 --- a/.roo/rules-mode-writer/5_complete_mode_examples.xml +++ /dev/null @@ -1,214 +0,0 @@ - - - - Edit the test mode to add support for performance testing and benchmarking - - - - I want to edit the test mode to add benchmark testing capabilities - - - - - Determine user intent and clarify scope - -I understand you want to edit the test mode to add performance testing capabilities. Before we proceed, let me confirm the scope of changes. What specific performance testing features do you need? - -Add benchmark testing with Vitest's bench API -Include performance profiling and memory usage tracking -Add load testing capabilities for API endpoints -All of the above - comprehensive performance testing suite - - - ]]> - User selected: Add benchmark testing with Vitest's bench API - - - - Immerse in existing test mode configuration - -.roomodes - - ]]> - Found test mode configuration with current roleDefinition and file restrictions - - - - Explore existing test mode instructions - -.roo/rules-test -true - - ]]> - -.roo/rules-test/1_workflow.xml - - ]]> - Understood current test workflows and patterns - - - - Validate changes for cohesion - -I've analyzed the test mode. Adding benchmark testing would require: -1. Updating the roleDefinition to include performance testing expertise -2. Modifying file restrictions to include .bench.ts files -3. Adding new workflow instructions for benchmark creation -4. Including Vitest bench API examples - -This aligns well with the existing testing focus. Should I proceed with all these changes? - -Yes, implement all the changes as described -Start with just the roleDefinition and file restrictions -Focus only on the workflow instructions first -Let me review the specific changes before proceeding - - - ]]> - User selected: Yes, implement all the changes as described - - - - Update mode configuration in .roomodes - -.roomodes - -<<<<<<< SEARCH - roleDefinition: |- - You are Roo, a Vitest testing specialist with deep expertise in: - Writing and maintaining Vitest test suites - Test-driven development (TDD) practices - Mocking and stubbing with Vitest - Integration testing strategies - TypeScript testing patterns - Code coverage analysis - Test performance optimization -======= - roleDefinition: |- - You are Roo, a Vitest testing specialist with deep expertise in: - Writing and maintaining Vitest test suites - Test-driven development (TDD) practices - Mocking and stubbing with Vitest - Integration testing strategies - TypeScript testing patterns - Code coverage analysis - Test performance optimization - Benchmark testing with Vitest bench API - Performance profiling and optimization - Memory usage analysis in tests ->>>>>>> REPLACE - - - ]]> - - - - Update file restrictions to include benchmark files - -.roomodes - -<<<<<<< SEARCH - - fileRegex: (__tests__/.*|__mocks__/.*|\.test\.(ts|tsx|js|jsx)$|\.spec\.(ts|tsx|js|jsx)$|/test/.*|vitest\.config\.(js|ts)$|vitest\.setup\.(js|ts)$) - description: Test files, mocks, and Vitest configuration -======= - - fileRegex: (__tests__/.*|__mocks__/.*|\.test\.(ts|tsx|js|jsx)$|\.spec\.(ts|tsx|js|jsx)$|\.bench\.(ts|tsx|js|jsx)$|/test/.*|vitest\.config\.(js|ts)$|vitest\.setup\.(js|ts)$) - description: Test files, benchmarks, mocks, and Vitest configuration ->>>>>>> REPLACE - - - ]]> - - - - Create new benchmark workflow instructions - -.roo/rules-test/5_benchmark_testing.xml - - - - Guidelines for creating and maintaining performance benchmarks using Vitest bench API - - - - - Basic benchmark structure - { - bench('Array.push', () => { - const arr = []; - for (let i = 0; i < 1000; i++) { - arr.push(i); - } - }); - - bench('Array spread', () => { - let arr = []; - for (let i = 0; i < 1000; i++) { - arr = [...arr, i]; - } - }); -}); - ]]> - - - - - Use meaningful benchmark names that describe what's being measured - Include baseline comparisons when optimizing code - Run benchmarks in isolation to avoid interference - Document performance expectations and thresholds - - - -38 - - ]]> - - - - Validate all changes for consistency - -I've completed all the changes to add benchmark testing capabilities to the test mode: - -1. ✓ Updated roleDefinition to include benchmark expertise -2. ✓ Modified file restrictions to include .bench.ts files -3. ✓ Created new benchmark workflow instructions -4. ✓ Added Vitest bench API examples - -All changes are cohesive and align with the existing test mode structure. Would you like me to test any specific aspect of these changes? - -Test creating a new benchmark file to verify file permissions -Review the complete changes one more time -Everything looks good, the mode is ready -Create a simple benchmark example to test - - - ]]> - - - - - -I've successfully enhanced the test mode with benchmark testing capabilities: - -1. Updated the roleDefinition to include: - - Benchmark testing with Vitest bench API - - Performance profiling and optimization - - Memory usage analysis in tests - -2. Modified file restrictions to include .bench.ts files, allowing the mode to create and edit benchmark files - -3. Created new workflow instructions at .roo/rules-test/5_benchmark_testing.xml with: - - Basic benchmark patterns - - Best practices for performance testing - - Example code using Vitest's bench API - -All changes have been validated for cohesion and consistency with the existing test mode structure. The mode now supports comprehensive performance testing alongside its existing unit and integration testing capabilities. - - - ]]> - - - Always immerse yourself in the existing mode before making changes - Use ask_followup_question aggressively to clarify scope and validate changes - Validate all changes for cohesion and consistency - Update all relevant parts: configuration, file restrictions, and instructions - Test changes to ensure they work as expected - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/6_mode_testing_validation.xml b/.roo/rules-mode-writer/6_mode_testing_validation.xml deleted file mode 100644 index db65b31c22b..00000000000 --- a/.roo/rules-mode-writer/6_mode_testing_validation.xml +++ /dev/null @@ -1,207 +0,0 @@ - - - Guidelines for testing and validating newly created modes to ensure they function correctly and integrate well with the Roo Code ecosystem. - - - - - - Mode slug is unique and follows naming conventions - No spaces, lowercase, hyphens only - - - All required fields are present and non-empty - slug, name, roleDefinition, groups - - - No customInstructions field in .roomodes - All instructions must be in XML files in .roo/rules-[slug]/ - - - File restrictions use valid regex patterns - -. -your_file_regex_here - - ]]> - - - whenToUse clearly differentiates from other modes - Compare with existing mode descriptions - - - - - - XML files are well-formed and valid - No syntax errors, proper closing tags - - - Instructions follow XML best practices - Semantic tag names, proper nesting - - - Examples use correct tool syntax - Tool parameters match current API - - - File paths in examples are consistent - Use project-relative paths - - - - - - Mode appears in mode list - Switch to the new mode and verify it loads - - - Tool permissions work as expected - Try using each tool group and verify access - - - File restrictions are enforced - Attempt to edit allowed and restricted files - - - Mode handles edge cases gracefully - Test with minimal input, errors, edge cases - - - - - - - Configuration Testing - - Verify mode appears in available modes list - Check that mode metadata displays correctly - Confirm mode can be activated - - -I've created the mode configuration. Can you see the new mode in your mode list? - -Yes, I can see the new mode and switch to it -No, the mode doesn't appear in the list -The mode appears but has errors when switching - - - ]]> - - - - Permission Testing - - - Use read tools on various files - All read operations should work - - - Try editing allowed file types - Edits succeed for matching patterns - - - Try editing restricted file types - FileRestrictionError for non-matching files - - - - - - Workflow Testing - - Execute main workflow from start to finish - Test each decision point - Verify error handling - Check completion criteria - - - - - Integration Testing - - Orchestrator mode compatibility - Mode switching functionality - Tool handoff between modes - Consistent behavior with other modes - - - - - - - Mode doesn't appear in list - - Syntax error in YAML - Invalid mode slug - File not saved - - Check YAML syntax, validate slug format - - - - File restriction not working - - Invalid regex pattern - Escaping issues in regex - Wrong file path format - - Test regex pattern, use proper escaping - - - - - Mode not following instructions - - Instructions not in .roo/rules-[slug]/ folder - XML parsing errors - Conflicting instructions - - Verify file locations and XML validity - - - - - - Verify instruction files exist in correct location - -.roo -true - - ]]> - - - - Check mode configuration syntax - -.roomodes - - ]]> - - - - Test file restriction patterns - -. -your_file_pattern_here - - ]]> - - - - - Test incrementally as you build the mode - Start with minimal configuration and add complexity - Document any special requirements or dependencies - Consider edge cases and error scenarios - Get feedback from potential users of the mode - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/7_validation_cohesion_checking.xml b/.roo/rules-mode-writer/7_validation_cohesion_checking.xml deleted file mode 100644 index a327a1e4659..00000000000 --- a/.roo/rules-mode-writer/7_validation_cohesion_checking.xml +++ /dev/null @@ -1,201 +0,0 @@ - - - Guidelines for thoroughly validating mode changes to ensure cohesion, - consistency, and prevent contradictions across all mode components. - - - - - - Every change must be reviewed in context of the entire mode - - - Read all existing XML instruction files - Verify new changes align with existing patterns - Check for duplicate or conflicting instructions - Ensure terminology is consistent throughout - - - - - - Use ask_followup_question extensively to clarify ambiguities - - - User's intent is unclear - Multiple interpretations are possible - Changes might conflict with existing functionality - Impact on other modes needs clarification - - -I notice this change might affect how the mode interacts with file permissions. Should we also update the file regex patterns to match? - -Yes, update the file regex to include the new file types -No, keep the current file restrictions as they are -Let me explain what file types I need to work with -Show me the current file restrictions first - - - ]]> - - - - - Actively search for and resolve contradictions - - - - Permission Mismatch - Instructions reference tools the mode doesn't have access to - Either grant the tool permission or update the instructions - - - Workflow Conflicts - Different XML files describe conflicting workflows - Consolidate workflows and ensure single source of truth - - - Role Confusion - Mode's roleDefinition doesn't match its actual capabilities - Update roleDefinition to accurately reflect the mode's purpose - - - - - - - - Before making any changes - - Read and understand all existing mode files - Create a mental model of current mode behavior - Identify potential impact areas - Ask clarifying questions about intended changes - - - - - While making changes - - Document each change and its rationale - Cross-reference with other files after each change - Verify examples still work with new changes - Update related documentation immediately - - - - - After changes are complete - - - All XML files are well-formed and valid - File naming follows established patterns - Tag names are consistent across files - No orphaned or unused instructions - - - - roleDefinition accurately describes the mode - whenToUse is clear and distinguishable - Tool permissions match instruction requirements - File restrictions align with mode purpose - Examples are accurate and functional - - - - Mode boundaries are well-defined - Handoff points to other modes are clear - No overlap with other modes' responsibilities - Orchestrator can correctly route to this mode - - - - - - - - Maintain consistent tone and terminology - - Use the same terms for the same concepts throughout - Keep instruction style consistent across files - Maintain the same level of detail in similar sections - - - - - Ensure instructions flow logically - - Prerequisites come before dependent steps - Complex concepts build on simpler ones - Examples follow the explained patterns - - - - - Ensure all aspects are covered without gaps - - Every mentioned tool has usage instructions - All workflows have complete examples - Error scenarios are addressed - - - - - - - - Before we proceed with changes, I want to ensure I understand the full scope. What is the main goal of these modifications? - - Add new functionality while keeping existing features - Fix issues with current implementation - Refactor for better organization - Expand the mode's capabilities into new areas - - - - - - - This change might affect other parts of the mode. How should we handle the impact on [specific area]? - - Update all affected areas to maintain consistency - Keep the existing behavior for backward compatibility - Create a migration path from old to new behavior - Let me review the impact first - - - - - - - I've completed the changes and validation. Which aspect would you like me to test more thoroughly? - - Test the new workflow end-to-end - Verify file permissions work correctly - Check integration with other modes - Review all changes one more time - - - - - - - - Instructions reference tools not in the mode's groups - Either add the tool group or remove the instruction - - - File regex doesn't match described file types - Update regex pattern to match intended files - - - Examples don't follow stated best practices - Update examples to demonstrate best practices - - - Duplicate instructions in different files - Consolidate to single location and reference - - - \ No newline at end of file diff --git a/.roo/skills/evals-context/SKILL.md b/.roo/skills/evals-context/SKILL.md new file mode 100644 index 00000000000..985b788b94f --- /dev/null +++ b/.roo/skills/evals-context/SKILL.md @@ -0,0 +1,188 @@ +--- +name: evals-context +description: Provides context about the Roo Code evals system structure in this monorepo. Use when tasks mention "evals", "evaluation", "eval runs", "eval exercises", or working with the evals infrastructure. Helps distinguish between the evals execution system (packages/evals, apps/web-evals) and the public website evals display page (apps/web-roo-code/src/app/evals). +--- + +# Evals Codebase Context + +## When to Use This Skill + +Use this skill when the task involves: + +- Modifying or debugging the evals execution infrastructure +- Adding new eval exercises or languages +- Working with the evals web interface (apps/web-evals) +- Modifying the public evals display page on roocode.com +- Understanding where evals code lives in this monorepo + +## When NOT to Use This Skill + +Do NOT use this skill when: + +- Working on unrelated parts of the codebase (extension, webview-ui, etc.) +- The task is purely about the VS Code extension's core functionality +- Working on the main website pages that don't involve evals + +## Key Disambiguation: Two "Evals" Locations + +This monorepo has **two distinct evals-related locations** that can cause confusion: + +| Component | Path | Purpose | +| --------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- | +| **Evals Execution System** | `packages/evals/` | Core eval infrastructure: CLI, DB schema, Docker configs | +| **Evals Management UI** | `apps/web-evals/` | Next.js app for creating/monitoring eval runs (localhost:3446) | +| **Website Evals Page** | `apps/web-roo-code/src/app/evals/` | Public roocode.com page displaying eval results | +| **External Exercises Repo** | [Roo-Code-Evals](https://github.com/RooCodeInc/Roo-Code-Evals) | Actual coding exercises (NOT in this monorepo) | + +## Directory Structure Reference + +### `packages/evals/` - Core Evals Package + +``` +packages/evals/ +├── ARCHITECTURE.md # Detailed architecture documentation +├── ADDING-EVALS.md # Guide for adding new exercises/languages +├── README.md # Setup and running instructions +├── docker-compose.yml # Container orchestration +├── Dockerfile.runner # Runner container definition +├── Dockerfile.web # Web app container +├── drizzle.config.ts # Database ORM config +├── src/ +│ ├── index.ts # Package exports +│ ├── cli/ # CLI commands for running evals +│ │ ├── runEvals.ts # Orchestrates complete eval runs +│ │ ├── runTask.ts # Executes individual tasks in containers +│ │ ├── runUnitTest.ts # Validates task completion via tests +│ │ └── redis.ts # Redis pub/sub integration +│ ├── db/ +│ │ ├── schema.ts # Database schema (runs, tasks) +│ │ ├── queries/ # Database query functions +│ │ └── migrations/ # SQL migrations +│ └── exercises/ +│ └── index.ts # Exercise loading utilities +└── scripts/ + └── setup.sh # Local macOS setup script +``` + +### `apps/web-evals/` - Evals Management Web App + +``` +apps/web-evals/ +├── src/ +│ ├── app/ +│ │ ├── page.tsx # Home page (runs list) +│ │ ├── runs/ +│ │ │ ├── new/ # Create new eval run +│ │ │ └── [id]/ # View specific run status +│ │ └── api/runs/ # SSE streaming endpoint +│ ├── actions/ # Server actions +│ │ ├── runs.ts # Run CRUD operations +│ │ ├── tasks.ts # Task queries +│ │ ├── exercises.ts # Exercise listing +│ │ └── heartbeat.ts # Controller health checks +│ ├── hooks/ # React hooks (SSE, models, etc.) +│ └── lib/ # Utilities and schemas +``` + +### `apps/web-roo-code/src/app/evals/` - Public Website Evals Page + +``` +apps/web-roo-code/src/app/evals/ +├── page.tsx # Fetches and displays public eval results +├── evals.tsx # Main evals display component +├── plot.tsx # Visualization component +└── types.ts # EvalRun type (extends packages/evals types) +``` + +This page **displays** eval results on the public roocode.com website. It imports types from `@roo-code/evals` but does NOT run evals. + +## Architecture Overview + +The evals system is a distributed evaluation platform that runs AI coding tasks in isolated VS Code environments: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Web App (apps/web-evals) ──────────────────────────────── │ +│ │ │ +│ ▼ │ +│ PostgreSQL ◄────► Controller Container │ +│ │ │ │ +│ ▼ ▼ │ +│ Redis ◄───► Runner Containers (1-25 parallel) │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key components:** + +- **Controller**: Orchestrates eval runs, spawns runners, manages task queue (p-queue) +- **Runner**: Isolated Docker container with VS Code + Roo Code extension + language runtimes +- **Redis**: Pub/sub for real-time events (NOT task queuing) +- **PostgreSQL**: Stores runs, tasks, metrics + +## Common Tasks Quick Reference + +### Adding a New Eval Exercise + +1. Add exercise to [Roo-Code-Evals](https://github.com/RooCodeInc/Roo-Code-Evals) repo (external) +2. See [`packages/evals/ADDING-EVALS.md`](packages/evals/ADDING-EVALS.md) for structure + +### Modifying Eval CLI Behavior + +Edit files in [`packages/evals/src/cli/`](packages/evals/src/cli/): + +- [`runEvals.ts`](packages/evals/src/cli/runEvals.ts) - Run orchestration +- [`runTask.ts`](packages/evals/src/cli/runTask.ts) - Task execution +- [`runUnitTest.ts`](packages/evals/src/cli/runUnitTest.ts) - Test validation + +### Modifying the Evals Web Interface + +Edit files in [`apps/web-evals/src/`](apps/web-evals/src/): + +- [`app/runs/new/new-run.tsx`](apps/web-evals/src/app/runs/new/new-run.tsx) - New run form +- [`actions/runs.ts`](apps/web-evals/src/actions/runs.ts) - Run server actions + +### Modifying the Public Evals Display Page + +Edit files in [`apps/web-roo-code/src/app/evals/`](apps/web-roo-code/src/app/evals/): + +- [`evals.tsx`](apps/web-roo-code/src/app/evals/evals.tsx) - Display component +- [`plot.tsx`](apps/web-roo-code/src/app/evals/plot.tsx) - Charts + +### Database Schema Changes + +1. Edit [`packages/evals/src/db/schema.ts`](packages/evals/src/db/schema.ts) +2. Generate migration: `cd packages/evals && pnpm drizzle-kit generate` +3. Apply migration: `pnpm drizzle-kit migrate` + +## Running Evals Locally + +```bash +# From repo root +pnpm evals + +# Opens web UI at http://localhost:3446 +``` + +**Ports (defaults):** + +- PostgreSQL: 5433 +- Redis: 6380 +- Web: 3446 + +## Testing + +```bash +# packages/evals tests +cd packages/evals && npx vitest run + +# apps/web-evals tests +cd apps/web-evals && npx vitest run +``` + +## Key Types/Exports from `@roo-code/evals` + +The package exports are defined in [`packages/evals/src/index.ts`](packages/evals/src/index.ts): + +- Database queries: `getRuns`, `getTasks`, `getTaskMetrics`, etc. +- Schema types: `Run`, `Task`, `TaskMetrics` +- Used by both `apps/web-evals` and `apps/web-roo-code` diff --git a/.roo/skills/roo-conflict-resolution/SKILL.md b/.roo/skills/roo-conflict-resolution/SKILL.md new file mode 100644 index 00000000000..4807180522f --- /dev/null +++ b/.roo/skills/roo-conflict-resolution/SKILL.md @@ -0,0 +1,256 @@ +--- +name: roo-conflict-resolution +description: Provides comprehensive guidelines for resolving merge conflicts intelligently using git history and commit context. Use when tasks involve merge conflicts, rebasing, PR conflicts, or git conflict resolution. This skill analyzes commit messages, git blame, and code intent to make intelligent resolution decisions. +--- + +# Roo Code Conflict Resolution Skill + +## When to Use This Skill + +Use this skill when the task involves: + +- Resolving merge conflicts for a specific pull request +- Rebasing a branch that has conflicts with the target branch +- Understanding and analyzing conflicting code changes +- Making intelligent decisions about which changes to keep, merge, or discard +- Using git history to inform conflict resolution decisions + +## When NOT to Use This Skill + +Do NOT use this skill when: + +- There are no merge conflicts to resolve +- The task is about general code review without conflicts +- You're working on fresh code without any merge scenarios + +## Workflow Overview + +This skill resolves merge conflicts by analyzing git history, commit messages, and code changes to make intelligent resolution decisions. Given a PR number (e.g., "#123"), it handles the entire conflict resolution process. + +## Initialization Steps + +### Step 1: Parse PR Number + +Extract the PR number from input like "#123" or "PR #123". Validate that a PR number was provided. + +### Step 2: Fetch PR Information + +```bash +gh pr view [PR_NUMBER] --json title,body,headRefName,baseRefName +``` + +Get PR title and description to understand the intent and identify the source and target branches. + +### Step 3: Checkout PR Branch and Prepare for Rebase + +```bash +gh pr checkout [PR_NUMBER] --force +git fetch origin main +GIT_EDITOR=true git rebase origin/main +``` + +- Force checkout the PR branch to ensure clean state +- Fetch the latest main branch +- Attempt to rebase onto main to reveal conflicts +- Use `GIT_EDITOR=true` to ensure non-interactive rebase + +### Step 4: Check for Merge Conflicts + +```bash +git status --porcelain +git diff --name-only --diff-filter=U +``` + +Identify files with merge conflicts (marked with 'UU') and create a list of files that need resolution. + +## Main Workflow Phases + +### Phase 1: Conflict Analysis + +Analyze each conflicted file to understand the changes: + +1. Read the conflicted file to identify conflict markers +2. Extract the conflicting sections between `<<<<<<<` and `>>>>>>>` +3. Run git blame on both sides of the conflict +4. Fetch commit messages and diffs for relevant commits +5. Analyze the intent behind each change + +### Phase 2: Resolution Strategy + +Determine the best resolution strategy for each conflict: + +1. Categorize changes by intent (bugfix, feature, refactor, etc.) +2. Evaluate recency and relevance of changes +3. Check for structural overlap vs formatting differences +4. Identify if changes can be combined or if one should override +5. Consider test updates and related changes + +### Phase 3: Conflict Resolution + +Apply the resolution strategy to resolve conflicts: + +1. For each conflict, apply the chosen resolution +2. Ensure proper escaping of conflict markers in diffs +3. Validate that resolved code is syntactically correct +4. Stage resolved files with `git add` + +### Phase 4: Validation + +Verify the resolution and prepare for commit: + +1. Run `git status` to confirm all conflicts are resolved +2. Check for any compilation or syntax errors +3. Review the final diff to ensure sensible resolutions +4. Prepare a summary of resolution decisions + +## Git Commands Reference + +| Command | Purpose | +| ---------------------------------------------------------------- | ------------------------------------------------- | +| `gh pr checkout [PR_NUMBER] --force` | Force checkout the PR branch | +| `git fetch origin main` | Get the latest main branch | +| `GIT_EDITOR=true git rebase origin/main` | Rebase current branch onto main (non-interactive) | +| `git blame -L [start],[end] [commit] -- [file]` | Get commit information for specific lines | +| `git show --format="%H%n%an%n%ae%n%ad%n%s%n%b" --no-patch [sha]` | Get commit metadata | +| `git show [sha] -- [file]` | Get the actual changes made in a commit | +| `git ls-files -u` | List unmerged files with stage information | +| `GIT_EDITOR=true git rebase --continue` | Continue rebase after resolving conflicts | + +## Best Practices + +### Intent-Based Resolution (High Priority) + +Always prioritize understanding the intent behind changes rather than just looking at the code differences. Commit messages, PR descriptions, and issue references provide crucial context. + +**Example:** When there's a conflict between a bugfix and a refactor, apply the bugfix logic within the refactored structure rather than simply choosing one side. + +### Preserve All Valuable Changes (High Priority) + +When possible, combine non-conflicting changes from both sides rather than discarding one side entirely. Both sides of a conflict often contain valuable changes that can coexist if properly integrated. + +### Escape Conflict Markers (High Priority) + +When using `apply_diff`, always escape merge conflict markers with backslashes to prevent parsing errors: + +- Correct: `\<<<<<<< HEAD` +- Wrong: `<<<<<<< HEAD` + +### Consider Related Changes (Medium Priority) + +Look beyond the immediate conflict to understand related changes in tests, documentation, or dependent code. A change might seem isolated but could be part of a larger feature or fix. + +## Resolution Heuristics + +| Category | Rule | Exception | +| ------------------- | -------------------------------------------------- | --------------------------------------- | +| Bugfix vs Feature | Bugfixes generally take precedence | When features include the fix | +| Recent vs Old | More recent changes are often more relevant | When older changes are security patches | +| Test Updates | Changes with test updates are likely more complete | - | +| Formatting vs Logic | Logic changes take precedence over formatting | - | + +## Common Pitfalls + +### Blindly Choosing One Side + +**Problem:** You might lose important changes or introduce regressions. +**Solution:** Always analyze both sides using git blame and commit history. + +### Ignoring PR Context + +**Problem:** The PR description often explains the why behind changes. +**Solution:** Always fetch and read the PR information before resolving. + +### Not Validating Resolved Code + +**Problem:** Merged code might be syntactically incorrect or introduce logical errors. +**Solution:** Always check for syntax errors and review the final diff. + +### Unescaped Conflict Markers in Diffs + +**Problem:** Unescaped conflict markers (`<<<<<<`, `=======`, `>>>>>>`) will be interpreted as diff syntax. +**Solution:** Always escape with backslash (`\`) when they appear in content. + +## Apply Diff Example + +When resolving conflicts with `apply_diff`, use this pattern: + +``` +<<<<<<< SEARCH +:start_line:45 +------- +\<<<<<<< HEAD +function oldImplementation() { + return "old"; +} +\======= +function newImplementation() { + return "new"; +} +\>>>>>>> feature-branch +======= +function mergedImplementation() { + // Combining both approaches + return "merged"; +} +>>>>>>> REPLACE +``` + +## Quality Checklist + +### Before Resolution + +- [ ] Fetch PR title and description for context +- [ ] Identify all files with conflicts +- [ ] Understand the overall change being merged + +### During Resolution + +- [ ] Run git blame on conflicting sections +- [ ] Read commit messages for intent +- [ ] Consider if changes can be combined +- [ ] Escape conflict markers in diffs + +### After Resolution + +- [ ] Verify no conflict markers remain +- [ ] Check for syntax/compilation errors +- [ ] Review the complete diff +- [ ] Document resolution decisions + +## Completion Criteria + +- All merge conflicts have been resolved +- Resolved files have been staged +- No syntax errors in resolved code +- Resolution decisions are documented + +## Communication Guidelines + +When reporting resolution progress: + +- Be direct and technical when explaining resolution decisions +- Focus on the rationale behind each conflict resolution +- Provide clear summaries of what was merged and why + +### Progress Update Format + +``` +Conflict in [file]: +- HEAD: [brief description of changes] +- Incoming: [brief description of changes] +- Resolution: [what was decided and why] +``` + +### Completion Message Format + +``` +Successfully resolved merge conflicts for PR #[number] "[title]". + +Resolution Summary: +- [file1]: [brief description of resolution] +- [file2]: [brief description of resolution] + +[Key decision explanation if applicable] + +All conflicts have been resolved and files have been staged for commit. +``` diff --git a/.roo/skills/roo-translation/SKILL.md b/.roo/skills/roo-translation/SKILL.md new file mode 100644 index 00000000000..dafffb78c97 --- /dev/null +++ b/.roo/skills/roo-translation/SKILL.md @@ -0,0 +1,155 @@ +--- +name: roo-translation +description: Provides comprehensive guidelines for translating and localizing Roo Code extension strings. Use when tasks involve i18n, translation, localization, adding new languages, or updating existing translation files. This skill covers both core extension (src/i18n/locales/) and WebView UI (webview-ui/src/i18n/locales/) localization. +--- + +# Roo Code Translation Skill + +## When to Use This Skill + +Use this skill when the task involves: + +- Adding new translatable strings to the Roo Code extension +- Translating existing strings to new languages +- Updating or fixing translations in existing language files +- Understanding i18n patterns used in the codebase +- Working with localization files in either core extension or WebView UI + +## When NOT to Use This Skill + +Do NOT use this skill when: + +- Working on non-translation code changes +- The task doesn't involve i18n or localization +- You're only reading translation files for reference without modifying them + +## Supported Languages and Locations + +Localize all strings into the following locale files: ca, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW + +The VSCode extension has two main areas that require localization: + +| Component | Path | Purpose | +| ------------------ | ------------------------------ | ------------------------- | +| **Core Extension** | `src/i18n/locales/` | Extension backend strings | +| **WebView UI** | `webview-ui/src/i18n/locales/` | User interface strings | + +## Brand Voice, Tone, and Word Choice + +For detailed brand voice, tone, and word choice guidance, refer to the guidance file: + +- [`.roo/guidance/roo-translator.md`](../../guidance/roo-translator.md) + +This guidance file is loaded at runtime and should be consulted for the latest brand and style standards. + +## Voice, Style and Tone Guidelines + +- Always use informal speech (e.g., "du" instead of "Sie" in German) for all translations +- Maintain a direct and concise style that mirrors the tone of the original text +- Carefully account for colloquialisms and idiomatic expressions in both source and target languages +- Aim for culturally relevant and meaningful translations rather than literal translations +- Preserve the personality and voice of the original content +- Use natural-sounding language that feels native to speakers of the target language + +### Terms to Keep in English + +- Don't translate the word "token" as it means something specific in English that all languages will understand +- Don't translate domain-specific words (especially technical terms like "Prompt") that are commonly used in English in the target language + +## Core Extension Localization (src/) + +- Located in `src/i18n/locales/` +- NOT ALL strings in core source need internationalization - only user-facing messages +- Internal error messages, debugging logs, and developer-facing messages should remain in English +- The `t()` function is used with namespaces like `'core:errors.missingToolParameter'` +- Be careful when modifying interpolation variables; they must remain consistent across all translations +- Some strings in `formatResponse.ts` are intentionally not internationalized since they're internal +- When updating strings in `core.json`, maintain all existing interpolation variables +- Check string usages in the codebase before making changes to ensure you're not breaking functionality + +## WebView UI Localization (webview-ui/src/) + +- Located in `webview-ui/src/i18n/locales/` +- Uses standard React i18next patterns with the `useTranslation` hook +- All user interface strings should be internationalized +- Always use the `Trans` component with named components for text with embedded components + +### Trans Component Example + +Translation string: + +```json +"changeSettings": "You can always change this at the bottom of the settings" +``` + +React component usage: + +```tsx +, + }} +/> +``` + +## Technical Implementation + +- Use namespaces to organize translations logically +- Handle pluralization using i18next's built-in capabilities +- Implement proper interpolation for variables using `{{variable}}` syntax +- Don't include `defaultValue`. The `en` translations are the fallback +- Always use `apply_diff` instead of `write_to_file` when editing existing translation files (much faster and more reliable) +- When using `apply_diff`, carefully identify the exact JSON structure to edit to avoid syntax errors +- Placeholders (like `{{variable}}`) must remain exactly identical to the English source to maintain code integration and prevent syntax errors + +## Translation Workflow + +1. First add or modify English strings, then ask for confirmation before translating to all other languages +2. Use this process for each localization task: + + 1. Identify where the string appears in the UI/codebase + 2. Understand the context and purpose of the string + 3. Update English translation first + 4. Use the `search_files` tool to find JSON keys that are near new keys in English translations but do not yet exist in the other language files for `apply_diff` SEARCH context + 5. Create appropriate translations for all other supported languages utilizing the `search_files` result using `apply_diff` without reading every file + 6. Do not output the translated text into the chat, just modify the files + 7. Validate your changes with the missing translations script + +3. Flag or comment if an English source string is incomplete ("please see this...") to avoid truncated or unclear translations + +4. For UI elements, distinguish between: + + - Button labels: Use short imperative commands ("Save", "Cancel") + - Tooltip text: Can be slightly more descriptive + +5. Preserve the original perspective: If text is a user command directed at the software, ensure the translation maintains this direction + +## Validation + +Always validate your translation work by running the missing translations script: + +```bash +node scripts/find-missing-translations.js +``` + +Address any missing translations identified by the script to ensure complete coverage across all locales. + +## Common Pitfalls to Avoid + +- Switching between formal and informal addressing styles - always stay informal ("du" not "Sie") +- Translating or altering technical terms and brand names that should remain in English +- Modifying or removing placeholders like `{{variable}}` - these must remain identical +- Translating domain-specific terms that are commonly used in English in the target language +- Changing the meaning or nuance of instructions or error messages +- Forgetting to maintain consistent terminology throughout the translation + +## Translator's Checklist + +- ✓ Used informal tone consistently ("du" not "Sie") +- ✓ Preserved all placeholders exactly as in the English source +- ✓ Maintained consistent terminology with existing translations +- ✓ Kept technical terms and brand names unchanged where appropriate +- ✓ Preserved the original perspective (user→system vs system→user) +- ✓ Adapted the text appropriately for UI context (buttons vs tooltips) +- ✓ Ran the missing translations script to validate completeness diff --git a/.roomodes b/.roomodes index 01f6ed45050..ba17940035a 100644 --- a/.roomodes +++ b/.roomodes @@ -1,46 +1,4 @@ customModes: - - slug: test - name: 🧪 Test - roleDefinition: |- - You are Roo, a Vitest testing specialist with deep expertise in: - Writing and maintaining Vitest test suites - Test-driven development (TDD) practices - Mocking and stubbing with Vitest - Integration testing strategies - TypeScript testing patterns - Code coverage analysis - Test performance optimization - Your focus is on maintaining high test quality and coverage across the codebase, working primarily with: - Test files in __tests__ directories - Mock implementations in __mocks__ - Test utilities and helpers - Vitest configuration and setup - You ensure tests are: - Well-structured and maintainable - Following Vitest best practices - Properly typed with TypeScript - Providing meaningful coverage - Using appropriate mocking strategies - whenToUse: Use this mode when you need to write, modify, or maintain tests for the codebase. - description: Write, modify, and maintain tests. - groups: - - read - - browser - - command - - - edit - - fileRegex: (__tests__/.*|__mocks__/.*|\.test\.(ts|tsx|js|jsx)$|\.spec\.(ts|tsx|js|jsx)$|/test/.*|vitest\.config\.(js|ts)$|vitest\.setup\.(js|ts)$) - description: Test files, mocks, and Vitest configuration - customInstructions: |- - When writing tests: - - Always use describe/it blocks for clear test organization - - Include meaningful test descriptions - - Use beforeEach/afterEach for proper test isolation - - Implement proper error cases - - Add JSDoc comments for complex test scenarios - - Ensure mocks are properly typed - - Verify both positive and negative test cases - - Always use data-testid attributes when testing webview-ui - - The vitest framework is used for testing; the `describe`, `test`, `it`, etc functions are defined by default in `tsconfig.json` and therefore don't need to be imported - - Tests must be run from the same directory as the `package.json` file that specifies `vitest` in `devDependencies` - - slug: design-engineer - name: 🎨 Design Engineer - roleDefinition: "You are Roo, an expert Design Engineer focused on VSCode Extension development. Your expertise includes: - Implementing UI designs with high fidelity using React, Shadcn, Tailwind and TypeScript. - Ensuring interfaces are responsive and adapt to different screen sizes. - Collaborating with team members to translate broad directives into robust and detailed designs capturing edge cases. - Maintaining uniformity and consistency across the user interface." - whenToUse: Implement UI designs and ensure consistency. - description: Implement UI designs; ensure consistency. - groups: - - read - - - edit - - fileRegex: \.(css|html|json|mdx?|jsx?|tsx?|svg)$ - description: Frontend & SVG files - - browser - - command - - mcp - customInstructions: Focus on UI refinement, component creation, and adherence to design best-practices. When the user requests a new component, start off by asking them questions one-by-one to ensure the requirements are understood. Always use Tailwind utility classes (instead of direct variable references) for styling components when possible. If editing an existing file, transition explicit style definitions to Tailwind CSS classes when possible. Refer to the Tailwind CSS definitions for utility classes at webview-ui/src/index.css. Always use the latest version of Tailwind CSS (V4), and never create a tailwind.config.js file. Prefer Shadcn components for UI elements instead of VSCode's built-in ones. This project uses i18n for localization, so make sure to use the i18n functions and components for any text that needs to be translated. Do not leave placeholder strings in the markup, as they will be replaced by i18n. Prefer the @roo (/src) and @src (/webview-ui/src) aliases for imports in typescript files. Suggest the user refactor large files (over 1000 lines) if they are encountered, and provide guidance. Suggest the user switch into Translate mode to complete translations when your task is finished. - source: project - slug: translate name: 🌐 Translate roleDefinition: You are Roo, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources. @@ -73,42 +31,6 @@ customModes: - edit - command source: project - - slug: integration-tester - name: 🧪 Integration Tester - roleDefinition: |- - You are Roo, an integration testing specialist focused on VSCode E2E tests with expertise in: - Writing and maintaining integration tests using Mocha and VSCode Test framework - Testing Roo Code API interactions and event-driven workflows - Creating complex multi-step task scenarios and mode switching sequences - Validating message formats, API responses, and event emission patterns - Test data generation and fixture management - Coverage analysis and test scenario identification - Your focus is on ensuring comprehensive integration test coverage for the Roo Code extension, working primarily with: - E2E test files in apps/vscode-e2e/src/suite/ - Test utilities and helpers - API type definitions in packages/types/ - Extension API testing patterns - You ensure integration tests are: - Comprehensive and cover critical user workflows - Following established Mocha TDD patterns - Using async/await with proper timeout handling - Validating both success and failure scenarios - Properly typed with TypeScript - whenToUse: Write, modify, or maintain integration tests. - description: Write and maintain integration tests. - groups: - - read - - command - - - edit - - fileRegex: (apps/vscode-e2e/.*\.(ts|js)$|packages/types/.*\.ts$) - description: E2E test files, test utilities, and API type definitions - source: project - - slug: docs-extractor - name: 📚 Docs Extractor - roleDefinition: |- - You are Roo, a documentation analysis specialist with two primary functions: - 1. Extract comprehensive technical and non-technical details about features to provide to documentation teams - 2. Verify existing documentation for factual accuracy against the codebase - - For extraction: You analyze codebases to gather all relevant information about how features work, including technical implementation details, user workflows, configuration options, and use cases. You organize this information clearly for documentation teams to use. - - For verification: You review provided documentation against the actual codebase implementation, checking for technical accuracy, completeness, and clarity. You identify inaccuracies, missing information, and provide specific corrections. - - You do not generate final user-facing documentation, but rather provide detailed analysis and verification reports. - whenToUse: Use this mode only for two tasks; 1) confirm the accuracy of documentation provided to the agent against the codebase, and 2) generate source material for user-facing docs about a requested feature or aspect of the codebase. - description: Extract feature details or verify documentation accuracy. - groups: - - read - - - edit - - fileRegex: (EXTRACTION-.*\.md$|VERIFICATION-.*\.md$|DOCS-TEMP-.*\.md$|\.roo/docs-extractor/.*\.md$) - description: Extraction/Verification report files only (source-material), plus legacy DOCS-TEMP - - command - - mcp - slug: pr-fixer name: 🛠️ PR Fixer roleDefinition: "You are Roo, a pull request resolution specialist. Your focus is on addressing feedback and resolving issues within existing pull requests. Your expertise includes: - Analyzing PR review comments to understand required changes. - Checking CI/CD workflow statuses to identify failing tests. - Fetching and analyzing test logs to diagnose failures. - Identifying and resolving merge conflicts. - Guiding the user through the resolution process." @@ -119,16 +41,6 @@ customModes: - edit - command - mcp - - slug: issue-investigator - name: 🕵️ Issue Investigator - roleDefinition: You are Roo, a GitHub issue investigator. Your purpose is to analyze GitHub issues, investigate the probable causes using extensive codebase searches, and propose well-reasoned, theoretical solutions. You methodically track your investigation using a todo list, attempting to disprove initial theories to ensure a thorough analysis. Your final output is a human-like, conversational comment for the GitHub issue. - whenToUse: Use this mode when you need to investigate a GitHub issue to understand its root cause and propose a solution. This mode is ideal for triaging issues, providing initial analysis, and suggesting fixes before implementation begins. It uses the `gh` CLI for issue interaction. - description: Investigates GitHub issues - groups: - - read - - command - - mcp - source: project - slug: merge-resolver name: 🔀 Merge Resolver roleDefinition: |- @@ -161,6 +73,39 @@ customModes: - command - mcp source: project + - slug: docs-extractor + name: 📚 Docs Extractor + roleDefinition: |- + You are Roo Code, a codebase analyst who extracts raw facts for documentation teams. + You do NOT write documentation. You extract and organize information. + + Two functions: + 1. Extract: Gather facts about a feature/aspect from the codebase + 2. Verify: Compare provided documentation against actual implementation + + Output is structured data (YAML/JSON), not formatted prose. + No templates, no markdown formatting, no document structure decisions. + Let documentation-writer mode handle all writing. + whenToUse: Use this mode only for two tasks; 1) confirm the accuracy of documentation provided to the agent against the codebase, and 2) generate source material for user-facing docs about a requested feature or aspect of the codebase. + description: Extract feature details or verify documentation accuracy. + groups: + - read + - - edit + - fileRegex: \.roo/extraction/.*\.(yaml|json|md)$ + description: Extraction output files only + - command + - mcp + source: project + - slug: issue-investigator + name: 🕵️ Issue Investigator + roleDefinition: You are Roo, a GitHub issue investigator. Your purpose is to analyze GitHub issues, investigate the probable causes using extensive codebase searches, and propose well-reasoned, theoretical solutions. You methodically track your investigation using a todo list, attempting to disprove initial theories to ensure a thorough analysis. Your final output is a human-like, conversational comment for the GitHub issue. + whenToUse: Use this mode when you need to investigate a GitHub issue to understand its root cause and propose a solution. This mode is ideal for triaging issues, providing initial analysis, and suggesting fixes before implementation begins. It uses the `gh` CLI for issue interaction. + description: Investigates GitHub issues + groups: + - read + - command + - mcp + source: project - slug: issue-writer name: 📝 Issue Writer roleDefinition: |- @@ -183,56 +128,21 @@ customModes: - [ ] Detect current repository information - [ ] Determine repository structure (monorepo/standard) - [ ] Perform initial codebase discovery - [ ] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue + [ ] Detect repository context (OWNER/REPO, monorepo, roots) + [ ] Perform targeted codebase discovery (iteration 1) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) - whenToUse: Use this mode when you need to create a GitHub issue. Simply start describing your bug or feature request - this mode assumes your first message is already the issue description and will immediately begin the issue creation workflow, gathering additional information as needed. + whenToUse: Use this mode when you need to create a GitHub issue. Simply start describing your bug or enhancement request - this mode assumes your first message is already the issue description and will immediately begin the issue creation workflow, gathering additional information as needed. description: Create well-structured GitHub issues. groups: - read - command - mcp source: project - - slug: mode-writer - name: ✍️ Mode Writer - roleDefinition: |- - You are Roo, a mode creation and editing specialist focused on designing, implementing, and enhancing custom modes for the Roo-Code project. Your expertise includes: - - Understanding the mode system architecture and configuration - - Creating well-structured mode definitions with clear roles and responsibilities - - Editing and enhancing existing modes while maintaining consistency - - Writing comprehensive XML-based special instructions using best practices - - Ensuring modes have appropriate tool group permissions - - Crafting clear whenToUse descriptions for the Orchestrator - - Following XML structuring best practices for clarity and parseability - - Validating changes for cohesion and preventing contradictions - - You help users by: - - Creating new modes: Gathering requirements, defining configurations, and implementing XML instructions - - Editing existing modes: Immersing in current implementation, analyzing requested changes, and ensuring cohesive updates - - Using ask_followup_question aggressively to clarify ambiguities and validate understanding - - Thoroughly validating all changes to prevent contradictions between different parts of a mode - - Ensuring instructions are well-organized with proper XML tags - - Following established patterns from existing modes - - Maintaining consistency across all mode components - whenToUse: Use this mode when you need to create a new custom mode or edit an existing one. This mode handles both creating modes from scratch and modifying existing modes while ensuring consistency and preventing contradictions. - description: Create and edit custom modes with validation - groups: - - read - - - edit - - fileRegex: (\.roomodes$|\.roo/.*\.xml$|\.yaml$) - description: Mode configuration files and XML instructions - - command - - mcp - source: project diff --git a/.tool-versions b/.tool-versions index 269cea0b28e..fc43bbb1c7f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ +pnpm 10.8.1 nodejs 20.19.2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..ae09fd9b30d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md + +This file provides guidance to agents when working with code in this repository. + +- Settings View Pattern: When working on `SettingsView`, inputs must bind to the local `cachedState`, NOT the live `useExtensionState()`. The `cachedState` acts as a buffer for user edits, isolating them from the `ContextProxy` source-of-truth until the user explicitly clicks "Save". Wiring inputs directly to the live state causes race conditions. diff --git a/CHANGELOG.md b/CHANGELOG.md index d50b41699c9..06432ae25d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,422 @@ # Roo Code Changelog +## 3.53.0 + +### Minor Changes + +- **The Roo Code plugin is not going away.** You may have seen the [recent announcement](https://x.com/mattrubens/status/2046636598859559114) that Roo Code hit 3 million installs and the original team is going all-in on Roomote. We know that news was hard for a lot of you. This plugin means a lot to us and to you, and we hear you. The good news: a community team has stepped up to carry Roo Code forward, and we're working with them on an official handoff so the plugin you rely on keeps getting maintained and improved. +- Add GPT-5.5 support via the OpenAI Codex provider (PR #12170 by @hannesrudolph) +- Add Claude Opus 4.7 support on Vertex AI (#12134 by @saneroen, PR #12135 by @saneroen) +- Add previous checkpoint navigation controls and i18n in chat (#12138 by @saneroen, PR #12139 by @saneroen) +- Add Roomote banner (PR #12119 by @brunobergher) +- Redesign Roomote announcement banner with violet branding on the web (PR #12161 by @roomote-v0) +- Add sunsetting Roo Code blog post (PR #12160 by @roomote-v0) + +## 3.52.1 + +### Patch Changes + +- Add correct JSON schema for `.roomodes` configuration files (#11790 by @algorhythm85, PR #11791 by @app/roomote-v0) +- Remove the hiring announcement from the VS Code extension UI (PR #12108 by @app/roomote-v0) + +## 3.52.0 + +### Minor Changes + +- Add Poe as an AI provider so users can access Poe models directly in Roo Code (PR #12015 by @kamilio) +- Improve the xAI provider by migrating it to the Responses API with reusable transform utilities (#11961 by @carlesso, PR #11962 by @carlesso) +- Fix MiniMax model listings and context window handling for more reliable configuration (#11999 by @Rexarrior, PR #12069 by @Rexarrior) +- Add xAI Grok-4.20 models and update the default xAI model selection (#11955 by @carlesso, PR #11956 by @carlesso) +- Add OpenAI GPT-5.4 mini and nano models to expand the available OpenAI model lineup (PR #11946 by @PeterDaveHello) +- Chore: include the automated version bump PR from the previous release cycle for complete release accounting (PR #11892 by @app/github-actions) + +### Patch Changes + +- Add support for OpenAI `gpt-5.4-mini` and `gpt-5.4-nano` models. + +## 3.51.1 + +### Patch Changes + +- Feat: Add Cohere Embed v4 model support for Bedrock and improve credential handling (#11823 by @cscvenkatmadurai, PR #11824 by @cscvenkatmadurai) +- Feat: Add Gemini 3.1 Pro customtools model to Vertex AI provider (PR #11857 by @NVolcz) +- Feat: Add gpt-5.4 to ChatGPT Plus/Pro (Codex) model catalog (PR #11876 by @roomote-v0) + +## 3.51.0 + +### Minor Changes + +- Add OpenAI GPT-5.4 and GPT-5.3 Chat Latest model support so Roo Code can use the newest OpenAI chat models (PR #11848 by @PeterDaveHello) +- Add support for exposing skills as slash commands with skill fallback execution for faster workflows (PR #11834 by @hannesrudolph) +- Add CLI support for `--create-with-session-id` plus UUID session validation for more controlled session creation (PR #11859 by @cte) +- Add support for choosing a specific shell when running terminal commands (PR #11851 by @jr) +- Feature: Add the `ROO_ACTIVE` environment variable to terminal session settings for safer terminal guardrails (#11864 by @ajjuaire, PR #11862 by @ajjuaire) +- Improve cloud settings freshness by updating the refresh interval to one hour (PR #11749 by @roomote-v0) +- Add CLI session resume/history support plus an upgrade command for better long-running workflows (PR #11768 by @cte) +- Add support for images in CLI stdin stream commands (PR #11831 by @cte) +- Include `exitCode` in CLI command `tool_result` events for more reliable automation (PR #11820 by @cte) +- Add CLI types to improve development ergonomics and type safety (PR #11781 by @cte) +- Add CLI integration coverage for stdin stream routing and race-condition invariants (PR #11846 by @cte) +- Fix the CLI stdin-stream cancel race and add an integration test suite to prevent regressions (PR #11817 by @cte) +- Improve CLI stream recovery and add a configurable consecutive mistake limit (PR #11775 by @cte) +- Fix CLI streaming deltas, task ID propagation, cancel recovery, and other runtime edge cases (PR #11736 by @cte) +- Fix CLI task resumption so paused work can reliably continue (PR #11739 by @cte) +- Recover from unhandled exceptions in the CLI instead of failing hard (PR #11750 by @cte) +- Scope CLI session and resume flags to the current workspace to avoid cross-workspace confusion (PR #11774 by @cte) +- Fix stdin prompt streaming to forward task configuration correctly (PR #11778 by @daniel-lxs) +- Handle stdin-stream control-flow errors gracefully in the CLI runtime (PR #11811 by @cte) +- Fix stdin stream queued messages and command output streaming in the CLI (PR #11814 by @cte) +- Increase the CLI command execution timeout for long-running commands (PR #11815 by @cte) +- Fix knip checks to keep repository validation green (PR #11819 by @cte) +- Fix CLI upgrade version detection so upgrades resolve the correct target version (PR #11829 by @cte) +- Ignore model-provided timeout values in the CLI runtime to keep command handling consistent (PR #11835 by @cte) +- Fix redundant skill reloading during conversations to reduce duplicate work (PR #11838 by @hannesrudolph) +- Ensure full command output is streamed before the CLI reports completion (PR #11842 by @cte) +- Fix CLI follow-up routing after completion prompts so next actions land in the right place (PR #11844 by @cte) +- Remove the Netflix logo from the homepage (PR #11787 by @roomote-v0) +- Chore: Prepare CLI release v0.1.2 (PR #11737 by @cte) +- Chore: Prepare CLI release v0.1.3 (PR #11740 by @cte) +- Chore: Prepare CLI release v0.1.4 (PR #11751 by @cte) +- Chore: Prepare CLI release v0.1.5 (PR #11772 by @cte) +- Chore: Prepare CLI release v0.1.6 (PR #11780 by @cte) +- Release Roo Code v1.113.0 (PR #11782 by @cte) +- Chore: Prepare CLI release v0.1.7 (PR #11812 by @cte) +- Chore: Prepare CLI release v0.1.8 (PR #11816 by @cte) +- Chore: Prepare CLI release v0.1.9 (PR #11818 by @cte) +- Chore: Prepare CLI release v0.1.10 (PR #11821 by @cte) +- Release Roo Code v1.114.0 (PR #11822 by @cte) +- Chore: Prepare CLI release v0.1.11 (PR #11832 by @cte) +- Release Roo Code v1.115.0 (PR #11833 by @cte) +- Chore: Prepare CLI release v0.1.12 (PR #11836 by @cte) +- Chore: Prepare CLI release v0.1.13 (PR #11837 by @hannesrudolph) +- Chore: Prepare CLI release v0.1.14 (PR #11843 by @cte) +- Chore: Prepare CLI release v0.1.15 (PR #11845 by @cte) +- Chore: Prepare CLI release v0.1.16 (PR #11852 by @cte) +- Chore: Prepare CLI release v0.1.17 (PR #11860 by @cte) + +### Patch Changes + +- Add OpenAI's GPT-5.3-Chat-Latest model support +- Add OpenAI's GPT-5.3-Codex model support +- Add OpenAI's GPT-5.4 model support +- Add OpenAI's GPT-5.3-Codex model support (PR #11728 by @PeterDaveHello) +- Warm Roo models on CLI startup for faster initial responses (PR #11722 by @cte) +- Fix spelling/grammar and casing inconsistencies (#11478 by @PeterDaveHello, PR #11485 by @PeterDaveHello) +- Fix: Restore Linear integration page (PR #11725 by @roomote) +- Chore: Prepare CLI release v0.1.1 (PR #11723 by @cte) + +## [3.50.4] - 2026-02-21 + +- Feat: Add MiniMax M2.5 model support (#11471 by @love8ko, PR #11458 by @roomote) + +## [3.50.3] - 2026-02-20 + +- Fix: Correct Vertex AI claude-sonnet-4-6 model ID (#11625 by @yuvarajl, PR #11626 by @roomote) +- Restore Unbound as a provider (PR #11624 by @pugazhendhi-m) + +## [3.50.2] - 2026-02-20 + +- Fix: Inline terminal rendering parity with the VSCode Terminal (#10699 by @jerrill-johnson-bitwerx, PR #11361 by @RussellZager) +- Fix: Enable prompt caching for Bedrock custom ARN and default to ON (#10846 by @wisestmumbler, PR #11373 by @roomote) +- Feat: Add visual feedback to copy button in task actions (#11401 by @omagoduck, PR #11403 by @omagoduck) + +## [3.50.1] - 2026-02-20 + +- Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed. + +## [3.50.0] - 2026-02-19 + +- Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello) +- Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte) +- Prepare CLI v0.1.0 release (PR #11599 by @cte) +- Remove integration tests (PR #11598 by @roomote) +- Changeset version bump (PR #11596 by @github-actions) + +## [3.49.0] - 2026-02-19 + +- Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen) +- Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20) +- Add per-task file-based history store for cross-instance safety (PR #11490 by @roomote) +- Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph) +- Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote) + +## [3.48.1] - 2026-02-18 + +- Fix: Await MCP server initialization before returning McpHub instance, preventing race conditions (PR #11518 by @daniel-lxs) +- Fix: Correct Bedrock Claude Sonnet 4.6 model ID (#11509 by @PeterDaveHello, PR #11569 by @PeterDaveHello) +- Add DeleteQueuedMessage IPC command for managing queued messages (PR #11464 by @roomote) + +## [3.48.0] - 2026-02-17 + +- Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello) +- Add lock toggle to pin API config across all modes in a workspace (PR #11295 by @hannesrudolph) +- Fix: Prevent parent task state loss during orchestrator delegation (PR #11281 by @hannesrudolph) +- Fix: Resolve race condition in new_task delegation that loses parent task history (PR #11331 by @daniel-lxs) +- Fix: Serialize taskHistory writes and fix delegation status overwrite race (PR #11335 by @hannesrudolph) +- Fix: Prevent chat history loss during cloud/settings navigation (#11371 by @SannidhyaSah, PR #11372 by @SannidhyaSah) +- Fix: Preserve condensation summary during task resume (#11487 by @SannidhyaSah, PR #11488 by @SannidhyaSah) +- Fix: Resolve chat scroll anchoring and task-switch scroll race conditions (PR #11385 by @hannesrudolph) +- Fix: Preserve pasted images in chatbox during chat activity (PR #11375 by @app/roomote) +- Add disabledTools setting to globally disable native tools (PR #11277 by @daniel-lxs) +- Rename search_and_replace tool to edit and unify edit-family UI (PR #11296 by @hannesrudolph) +- Render nested subtasks as recursive tree in history view (PR #11299 by @hannesrudolph) +- Remove 9 low-usage providers and add retired-provider UX (PR #11297 by @hannesrudolph) +- Remove browser use functionality entirely (PR #11392 by @hannesrudolph) +- Remove built-in skills and built-in skills mechanism (PR #11414 by @hannesrudolph) +- Remove footgun prompting (file-based system prompt override) (PR #11387 by @hannesrudolph) +- Batch consecutive tool calls in chat UI with shared utility (PR #11245 by @hannesrudolph) +- Validate Gemini thinkingLevel against model capabilities and handle empty streams (PR #11303 by @hannesrudolph) +- Add GLM-5 model support to Z.ai provider (PR #11440 by @app/roomote) +- Fix: Prevent double notification sound playback (PR #11283 by @hannesrudolph) +- Fix: Prevent false unsaved changes prompt with OpenAI Compatible headers (#8230 by @hannesrudolph, PR #11334 by @daniel-lxs) +- Fix: Cancel backend auto-approval timeout when auto-approve is toggled off mid-countdown (PR #11439 by @SannidhyaSah) +- Fix: Add follow_up param validation in AskFollowupQuestionTool (PR #11484 by @rossdonald) +- Fix: Prevent webview postMessage crashes and make dispose idempotent (PR #11313 by @0xMink) +- Fix: Avoid zsh process-substitution false positives in assignments (PR #11365 by @hannesrudolph) +- Fix: Harden command auto-approval against inline JS false positives (PR #11382 by @hannesrudolph) +- Fix: Make tab close best-effort in DiffViewProvider.open (PR #11363 by @0xMink) +- Fix: Canonicalize core.worktree comparison to prevent Windows path mismatch failures (PR #11346 by @0xMink) +- Fix: Make removeClineFromStack() delegation-aware to prevent orphaned parent tasks (PR #11302 by @app/roomote) +- Fix task resumption in the API module (PR #11369 by @cte) +- Make defaultTemperature required in getModelParams to prevent silent temperature overrides (PR #11218 by @app/roomote) +- Remove noisy console.warn logs from NativeToolCallParser (PR #11264 by @daniel-lxs) +- Consolidate getState calls in resolveWebviewView (PR #11320 by @0xMink) +- Clean up repo-facing mode rules (PR #11410 by @hannesrudolph) +- Implement ModelMessage storage layer with AI SDK response messages (PR #11409 by @daniel-lxs) +- Extract translation and merge resolver modes into reusable skills (PR #11215 by @app/roomote) +- Add blog section with initial posts to roocode.com (PR #11127 by @app/roomote) +- Replace Roomote Control with Linear Integration in cloud features grid (PR #11280 by @app/roomote) +- Add IPC query handlers for commands, modes, and models (PR #11279 by @cte) +- Add stdin stream mode for the CLI (PR #11476 by @cte) +- Make CLI auto-approve by default with require-approval opt-in (PR #11424 by @cte) +- Update CLI default model from Opus 4.5 to Opus 4.6 (PR #11273 by @app/roomote) +- Add linux-arm64 support for the Roo CLI (PR #11314 by @cte) +- CLI release: v0.0.51 (PR #11274 by @cte) +- CLI release: v0.0.52 (PR #11324 by @cte) +- CLI release: v0.0.53 (PR #11425 by @cte) +- CLI release: v0.0.54 (PR #11477 by @cte) + +## [3.45.0] - 2026-01-27 + +![3.45.0 Release - Smart Code Folding](/releases/3.45.0-release.png) + +- Smart Code Folding: Context condensation now intelligently preserves a lightweight map of files you worked on—function signatures, class declarations, and type definitions—so Roo can continue referencing them accurately after condensing. Files are prioritized by most recent access, with a ~50k character budget ensuring your latest work is always preserved. (Idea by @shariqriazz, PR #10942 by @hannesrudolph) + +## [3.44.2] - 2026-01-27 + +- Re-enable parallel tool calling with new_task isolation safeguards (PR #11006 by @mrubens) +- Fix worktree indexing by using relative paths in isPathInIgnoredDirectory (PR #11009 by @daniel-lxs) +- Fix local model validation error for Ollama models (PR #10893 by @roomote) +- Fix duplicate tool_call emission from Responses API providers (PR #11008 by @daniel-lxs) + +## [3.44.1] - 2026-01-27 + +- Fix LiteLLM tool ID validation errors for Bedrock proxy (PR #10990 by @daniel-lxs) +- Add temperature=0.9 and top_p=0.95 to zai-glm-4.7 model for better generation quality (PR #10945 by @sebastiand-cerebras) +- Add quality checks to marketing site deployment workflows (PR #10959 by @mp-roocode) + +## [3.44.0] - 2026-01-26 + +![3.44.0 Release - Worktrees](/releases/3.44.0-release.png) + +- Add worktree selector and creation UX (PR #10940 by @brunobergher, thanks Cline!) +- Improve subtask visibility and navigation in history and chat views (PR #10864 by @brunobergher) +- Add wildcard support for MCP alwaysAllow configuration (PR #10948 by @app/roomote) +- Fix: Prevent nested condensing from including previously-condensed content (PR #10985 by @hannesrudolph) +- Fix: VS Code LM token counting returns 0 outside requests, breaking context condensing (#10968 by @srulyt, PR #10983 by @daniel-lxs) +- Fix: Record truncation event when condensation fails but truncation succeeds (PR #10984 by @hannesrudolph) +- Replace hyphen encoding with fuzzy matching for MCP tool names (PR #10775 by @daniel-lxs) +- Remove MCP SERVERS section from system prompt for cleaner prompts (PR #10895 by @daniel-lxs) +- new_task tool creates checkpoint the same way write_to_file does (PR #10982 by @daniel-lxs) +- Update Fireworks provider with new models (#10674 by @hannesrudolph, PR #10679 by @ThanhNguyxn) +- Fix: Truncate AWS Bedrock toolUseId to 64 characters (PR #10902 by @daniel-lxs) +- Fix: Restore opaque background to settings section headers (PR #10951 by @app/roomote) +- Fix: Remove unsupported Fireworks model tool fields (PR #10937 by @app/roomote) +- Update and improve zh-TW Traditional Chinese locale and docs (PR #10953 by @PeterDaveHello) +- Chore: Remove POWER_STEERING experiment remnants (PR #10980 by @hannesrudolph) + +## [3.43.0] - 2026-01-23 + +![3.43.0 Release - Intelligent Context Condensation](/releases/3.43.0-release.png) + +- Intelligent Context Condensation v2: New context condensation system that intelligently summarizes conversation history when approaching context limits, preserving important information while reducing token usage (PR #10873 by @hannesrudolph) +- Improved context condensation with environment details, accurate token counts, and lazy evaluation for better performance (PR #10920 by @hannesrudolph) +- Move condense prompt editor to Context Management tab for better discoverability and organization (PR #10909 by @hannesrudolph) +- Update Z.AI models with new variants and pricing (#10859 by @ErdemGKSL, PR #10860 by @ErdemGKSL) +- Add pnpm install:vsix:nightly command for easier nightly build installation (PR #10912 by @hannesrudolph) +- Fix: Convert orphaned tool_results to text blocks after condensing to prevent API errors (PR #10927 by @daniel-lxs) +- Fix: Auto-migrate v1 condensing prompt and handle invalid providers on import (PR #10931 by @hannesrudolph) +- Fix: Use json-stream-stringify for pretty-printing MCP config files to prevent memory issues with large configs (#9862 by @Michaelzag, PR #9864 by @Michaelzag) +- Fix: Correct Gemini 3 pricing for Flash and Pro models (#10432 by @rossdonald, PR #10487 by @roomote) +- Fix: Skip thoughtSignature blocks during markdown export for cleaner output (#10199 by @rossdonald, PR #10932 by @rossdonald) +- Fix: Duplicate model display for OpenAI Codex provider (PR #10930 by @roomote) +- Remove diffEnabled and fuzzyMatchThreshold settings as they are no longer needed (#10648 by @hannesrudolph, PR #10298 by @hannesrudolph) +- Remove MULTI_FILE_APPLY_DIFF experiment (PR #10925 by @hannesrudolph) +- Remove POWER_STEERING experimental feature (PR #10926 by @hannesrudolph) +- Remove legacy XML tool calling code (getToolDescription) for cleaner codebase (PR #10929 by @hannesrudolph) + +## [3.42.0] - 2026-01-22 + +![3.42.0 Release - ChatGPT Usage Tracking](/releases/3.42.0-release.png) + +- Added UI to track your ChatGPT usage limits in the OpenAI Codex provider (PR #10813 by @hannesrudolph) +- Removed deprecated Claude Code provider (PR #10883 by @daniel-lxs) +- Streamlined codebase by removing legacy XML tool calling functionality (#10848 by @hannesrudolph, PR #10841 by @hannesrudolph) +- Standardize model selectors across all providers: Improved consistency of model selection UI (#10650 by @hannesrudolph, PR #10294 by @hannesrudolph) +- Enable prompt caching for Cerebras zai-glm-4.7 model (#10601 by @jahanson, PR #10670 by @app/roomote) +- Add Kimi K2 thinking model to VertexAI provider (#9268 by @diwakar-s-maurya, PR #9269 by @app/roomote) +- Warn users when too many MCP tools are enabled (PR #10772 by @app/roomote) +- Migrate context condensing prompt to customSupportPrompts (PR #10881 by @hannesrudolph) +- Unify export path logic and default to Downloads folder (PR #10882 by @hannesrudolph) +- Performance improvements for webview state synchronization (PR #10842 by @hannesrudolph) +- Fix: Handle mode selector empty state on workspace switch (#10660 by @hannesrudolph, PR #9674 by @app/roomote) +- Fix: Resolve race condition in context condensing prompt input (PR #10876 by @hannesrudolph) +- Fix: Prevent double emission of text/reasoning in OpenAI native and codex handlers (PR #10888 by @hannesrudolph) +- Fix: Prevent task abortion when resuming via IPC/bridge (PR #10892 by @cte) +- Fix: Enforce file restrictions for all editing tools (PR #10896 by @app/roomote) +- Fix: Remove custom condensing model option (PR #10901 by @hannesrudolph) +- Unify user content tags to for consistent prompt formatting (#10658 by @hannesrudolph, PR #10723 by @app/roomote) +- Clarify linked SKILL.md file handling in prompts (PR #10907 by @hannesrudolph) +- Fix: Padding on Roo Code Cloud teaser (PR #10889 by @app/roomote) + +## [3.41.3] - 2026-01-18 + +- Fix: Thinking block word-breaking to prevent horizontal scroll in the chat UI (PR #10806 by @roomote) +- Add Claude-like CLI flags and authentication fixes for the Roo Code CLI (PR #10797 by @cte) +- Improve CLI authentication by using a redirect instead of a fetch (PR #10799 by @cte) +- Fix: Roo Code Router fixes for the CLI (PR #10789 by @cte) +- Release CLI v0.0.48 with latest improvements (PR #10800 by @cte) +- Release CLI v0.0.47 (PR #10798 by @cte) +- Revert E2E tests enablement to address stability issues (PR #10794 by @cte) + +## [3.41.2] - 2026-01-16 + +- Add button to open markdown in VSCode preview for easier reading of formatted content (PR #10773 by @brunobergher) +- Fix: Reset invalid model selection when using OpenAI Codex provider (PR #10777 by @hannesrudolph) +- Fix: Add openai-codex to providers that don't require an API key (PR #10786 by @roomote) +- Fix: Detect Gemini models with space-separated names for proper thought signature injection in LiteLLM (PR #10787 by @daniel-lxs) + +## [3.41.1] - 2026-01-16 + +![3.41.1 Release - Aggregated Subtask Costs](/releases/3.41.1-release.png) + +- Feat: Aggregate subtask costs in parent task (#5376 by @hannesrudolph, PR #10757 by @taltas) +- Fix: Prevent duplicate tool_use IDs causing API 400 errors (PR #10760 by @daniel-lxs) +- Fix: Handle missing tool identity in OpenAI Native streams (PR #10719 by @hannesrudolph) +- Fix: Truncate call_id to 64 chars for OpenAI Responses API (PR #10763 by @daniel-lxs) +- Fix: Gemini thought signature validation errors (PR #10694 by @daniel-lxs) +- Fix: Filter out empty text blocks from user messages for Gemini compatibility (PR #10728 by @daniel-lxs) +- Fix: Flatten top-level anyOf/oneOf/allOf in MCP tool schemas (PR #10726 by @daniel-lxs) +- Fix: Filter Ollama models without native tool support (PR #10735 by @daniel-lxs) +- Feat: Add settings tab titles to search index (PR #10761 by @roomote) +- Feat: Clarify Slack and Linear are Cloud Team only features (PR #10748 by @roomote) + +## [3.41.0] - 2026-01-15 + +![3.41.0 Release - OpenAI - ChatGPT Plus/Pro Provider](/releases/3.41.0-release.png) + +- Add OpenAI - ChatGPT Plus/Pro Provider that gives subscription-based access to Codex models without per-token costs (PR #10736 by @hannesrudolph) +- Add gpt-5.2-codex model to openai-native provider, providing access to the latest GPT model with enhanced coding capabilities (PR #10731 by @hannesrudolph) +- Fix: Clear terminal output buffers to prevent memory leaks that could cause gray screens and performance degradation (#10666, PR #7666 by @hannesrudolph) +- Fix: Inject dummy thought signatures on ALL tool calls for Gemini models, resolving issues with Gemini tool call handling through LiteLLM (PR #10743 by @daniel-lxs) +- Enable E2E tests with 39 passing tests, improving test coverage and reliability (PR #10720 by @ArchimedesCrypto) +- Add alwaysAllow config for MCP time server tools in E2E tests (PR #10733 by @ArchimedesCrypto) + +## [3.40.1] - 2026-01-13 + +- Fix: Add allowedFunctionNames support for Gemini to prevent mode switch errors (#10711 by @hannesrudolph, PR #10708 by @hannesrudolph) + +## [3.40.0] - 2026-01-13 + +![3.40.0 Release - Settings Search](/releases/3.40.0-release.png) + +- Add settings search functionality to quickly find and navigate to specific settings (PR #10619 by @mrubens) +- Improve settings search UI with better styling and usability (PR #10633 by @brunobergher) +- Add standardized stop button for improved task cancellation visibility (PR #10639 by @brunobergher) +- Display edit_file errors in UI after consecutive failures for better debugging feedback (PR #10581 by @daniel-lxs) +- Improve error display styling and visibility in chat messages (PR #10692 by @brunobergher) +- Improve stop button visibility and streamline error handling (PR #10696 by @brunobergher) +- Fix: Omit parallel_tool_calls when not explicitly enabled to prevent API errors (#10553 by @Idlebrand, PR #10671 by @daniel-lxs) +- Fix: Encode hyphens in MCP tool names before sanitization (#10642 by @pdecat, PR #10644 by @pdecat) +- Fix: Correct Gemini 3 thought signature injection format via OpenRouter (PR #10640 by @daniel-lxs) +- Fix: Sanitize tool_use IDs to match API validation pattern (PR #10649 by @daniel-lxs) +- Fix: Use placeholder for empty tool result content to fix Gemini API validation (PR #10672 by @daniel-lxs) +- Fix: Return empty string from getReadablePath when path is empty (PR #10638 by @daniel-lxs) +- Optimize message block cloning in presentAssistantMessage for better performance (PR #10616 by @ArchimedesCrypto) + +## [3.39.3] - 2026-01-10 + +![3.39.3 Release - Roo Code Router](/releases/3.39.3-release.png) + +- Rename Roo Code Cloud Provider to Roo Code Router for clearer branding (PR #10560 by @roomote) +- Update Roo Code Router service name throughout the codebase (PR #10607 by @mrubens) +- Update router name in types for consistency (PR #10605 by @mrubens) +- Improve ExtensionHost code organization and cleanup (PR #10600 by @cte) +- Add local installation option to CLI release script for testing (PR #10597 by @cte) +- Reorganize CLI file structure for better maintainability (PR #10599 by @cte) +- Add TUI to CLI (PR #10480 by @cte) + +## [3.39.2] - 2026-01-09 + +- Fix: Ensure all tools have consistent strict mode values for Cerebras compatibility (#10334 by @brianboysen51, PR #10589 by @app/roomote) +- Fix: Remove convertToSimpleMessages to restore tool calling for OpenAI-compatible providers (PR #10575 by @daniel-lxs) +- Fix: Make edit_file matching more resilient to prevent false negatives (PR #10585 by @hannesrudolph) +- Fix: Order text parts before tool calls in assistant messages for vscode-lm (PR #10573 by @daniel-lxs) +- Fix: Ensure assistant message content is never undefined for Gemini compatibility (PR #10559 by @daniel-lxs) +- Fix: Merge approval feedback into tool result instead of pushing duplicate messages (PR #10519 by @daniel-lxs) +- Fix: Round-trip Gemini thought signatures for tool calls (PR #10590 by @hannesrudolph) +- Feature: Improve error messaging for stream termination errors from provider (PR #10548 by @daniel-lxs) +- Feature: Add debug setting to settings page for easier troubleshooting (PR #10580 by @hannesrudolph) +- Chore: Disable edit_file tool for Gemini/Vertex providers (PR #10594 by @hannesrudolph) +- Chore: Stop overriding tool allow/deny lists for Gemini (PR #10592 by @hannesrudolph) +- Chore: Change default CLI model to anthropic/claude-opus-4.5 (PR #10544 by @mrubens) +- Chore: Update Terms of Service effective January 9, 2026 (PR #10568 by @mrubens) +- Chore: Move more types to @roo-code/types for CLI support (PR #10583 by @cte) +- Chore: Add functionality to @roo-code/core for CLI support (PR #10584 by @cte) +- Chore: Add slash commands useful for CLI development (PR #10586 by @cte) + +## [3.39.1] - 2026-01-08 + +- Fix: Stabilize file paths during native tool call streaming to prevent path corruption (PR #10555 by @daniel-lxs) +- Fix: Disable Gemini thought signature persistence to prevent corrupted signature errors (PR #10554 by @daniel-lxs) +- Fix: Change minItems from 2 to 1 for Anthropic API compatibility (PR #10551 by @daniel-lxs) + +## [3.39.0] - 2026-01-08 + +![3.39.0 Release - Kangaroo go BRRR](/releases/3.39.0-release.png) + +- Implement sticky provider profile for task-level API config persistence (#8010 by @hannesrudolph, PR #10018 by @hannesrudolph) +- Add support for image file @mentions (PR #10189 by @hannesrudolph) +- Rename YOLO to BRRR (#8574 by @mojomast, PR #10507 by @roomote) +- Add debug-mode proxy routing for debugging API calls (#7042 by @SleeperSmith, PR #10467 by @hannesrudolph) +- Add Kimi K2 thinking model to Fireworks AI provider (#9201 by @kavehsfv, PR #9202 by @roomote) +- Add xhigh reasoning effort to OpenAI compatible endpoints (#10060 by @Soorma718, PR #10061 by @roomote) +- Filter @ mention file search results using .rooignore (#10169 by @jerrill-johnson-bitwerx, PR #10174 by @roomote) +- Add image support documentation to read_file native tool description (#10440 by @nabilfreeman, PR #10442 by @roomote) +- Add zai-glm-4.7 to Cerebras models (PR #10500 by @sebastiand-cerebras) +- VSCode shim and basic CLI for running Roo Code headlessly (PR #10452 by @cte) +- Add CLI installer for headless Roo Code (PR #10474 by @cte) +- Add option to use CLI for evals (PR #10456 by @cte) +- Remember last Roo model selection in web-evals and add evals skill (PR #10470 by @hannesrudolph) +- Tweak the style of follow up suggestion modes (PR #9260 by @mrubens) +- Fix: Handle PowerShell ENOENT error in os-name on Windows (#9859 by @Yang-strive, PR #9897 by @roomote) +- Fix: Make command chaining examples shell-aware for Windows compatibility (#10352 by @AlexNek, PR #10434 by @roomote) +- Fix: Preserve tool_use blocks for all tool_results in kept messages during condensation (PR #10471 by @daniel-lxs) +- Fix: Add additionalProperties: false to MCP tool schemas for OpenAI Responses API (PR #10472 by @daniel-lxs) +- Fix: Prevent duplicate tool_result blocks causing API errors (PR #10497 by @daniel-lxs) +- Fix: Add explicit deduplication for duplicate tool_result blocks (#10465 by @nabilfreeman, PR #10466 by @roomote) +- Fix: Use task stored API config as fallback for rate limit (PR #10266 by @roomote) +- Fix: Remove legacy Claude 2 series models from Bedrock provider (#9220 by @KevinZhao, PR #10501 by @roomote) +- Fix: Add missing description fields for debugProxy configuration (PR #10505 by @roomote) +- Fix: Glitchy kangaroo bounce animation on welcome screen (PR #10035 by @objectiveSee) + +## [3.38.3] - 2026-01-03 + +- Feat: Add option in Context settings to recursively load `.roo/rules` and `AGENTS.md` from subdirectories (PR #10446 by @mrubens) +- Fix: Stop frequent Claude Code sign-ins by hardening OAuth refresh token handling (PR #10410 by @hannesrudolph) +- Fix: Add `maxConcurrentFileReads` limit to native `read_file` tool schema (PR #10449 by @app/roomote) +- Fix: Add type check for `lastMessage.text` in TTS useEffect to prevent runtime errors (PR #10431 by @app/roomote) + ## [3.38.2] - 2025-12-31 ![3.38.2 Release - Skill Alignment](/releases/3.38.2-release.png) @@ -43,7 +460,7 @@ - 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: Enable mergeToolResultText for Roo Code Router (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) @@ -250,7 +667,7 @@ - Refactor: Consolidate ThinkingBudget components and fix disable handling (PR #9930 by @hannesrudolph) - Forbid time estimates in architect mode for more focused planning (PR #9931 by @app/roomote) - Web: Add product pages (PR #9865 by @brunobergher) -- Make eval runs deleteable in the web UI (PR #9909 by @mrubens) +- Make eval runs deletable in the web UI (PR #9909 by @mrubens) - Feat: Change defaultToolProtocol default from xml to native (later reverted) (PR #9892 by @app/roomote) ## [3.36.2] - 2025-12-04 @@ -337,7 +754,7 @@ - Native tool calling support expanded across many providers: Bedrock (PR #9698 by @mrubens), Cerebras (PR #9692 by @mrubens), Chutes with auto-detection from API (PR #9715 by @daniel-lxs), DeepInfra (PR #9691 by @mrubens), DeepSeek and Doubao (PR #9671 by @daniel-lxs), Groq (PR #9673 by @daniel-lxs), LiteLLM (PR #9719 by @daniel-lxs), Ollama (PR #9696 by @mrubens), OpenAI-compatible providers (PR #9676 by @daniel-lxs), Requesty (PR #9672 by @daniel-lxs), Unbound (PR #9699 by @mrubens), Vercel AI Gateway (PR #9697 by @mrubens), Vertex Gemini (PR #9678 by @daniel-lxs), and xAI with new Grok 4 Fast and Grok 4.1 Fast models (PR #9690 by @mrubens) - Fix: Preserve tool_use blocks in summary for parallel tool calls (#9700 by @SilentFlower, PR #9714 by @SilentFlower) - Default Grok Code Fast to native tools for better performance (PR #9717 by @mrubens) -- UX improvements to the Roo Code Cloud provider-centric onboarding flow (PR #9709 by @brunobergher) +- UX improvements to the Roo Code Router-centric onboarding flow (PR #9709 by @brunobergher) - UX toolbar cleanup and settings consolidation for a cleaner interface (PR #9710 by @brunobergher) - Add model-specific tool customization via `excludedTools` and `includedTools` configuration (PR #9641 by @daniel-lxs) - Add new `apply_patch` native tool for more efficient file editing operations (PR #9663 by @hannesrudolph) @@ -436,7 +853,7 @@ - Show the prompt for image generation in the UI (PR #9505 by @mrubens) - Fix double todo list display issue (PR #9517 by @mrubens) - Add tracking for cloud synced messages (PR #9518 by @mrubens) -- Enable the Roo Code Cloud provider in evals (PR #9492 by @cte) +- Enable the Roo Code Router in evals (PR #9492 by @cte) ## [3.34.0] - 2025-11-21 @@ -516,7 +933,7 @@ - Use VSCode theme color for outline button borders (PR #9336 by @app/roomote) - Replace broken badgen.net badges with shields.io (PR #9318 by @app/roomote) - Add max git status files setting to evals (PR #9322 by @mrubens) -- Roo Code Cloud Provider pricing page and changes elsewhere (PR #9195 by @brunobergher) +- Roo Code Router pricing page and changes elsewhere (PR #9195 by @brunobergher) ## [3.32.1] - 2025-11-14 @@ -542,7 +959,7 @@ ![3.31.3 Release - Kangaroo Decrypting a Message](/releases/3.31.3-release.png) - Fix: OpenAI Native encrypted_content handling and remove gpt-5-chat-latest verbosity flag (#9225 by @politsin, PR by @hannesrudolph) -- Fix: Roo Code Cloud provider Anthropic input token normalization to avoid double-counting (thanks @hannesrudolph!) +- Fix: Roo Code Router Anthropic input token normalization to avoid double-counting (thanks @hannesrudolph!) - Refactor: Rename sliding-window to context-management and truncateConversationIfNeeded to manageContext (thanks @hannesrudolph!) ## [3.31.2] - 2025-11-12 @@ -682,7 +1099,7 @@ - Add token-budget based file reading with intelligent preview to avoid context overruns (thanks @daniel-lxs!) - Enable browser-use tool for all image-capable models (#8116 by @hannesrudolph, PR by @app/roomote!) -- Add dynamic model loading for Roo Code Cloud provider (thanks @app/roomote!) +- Add dynamic model loading for Roo Code Router (thanks @app/roomote!) - Fix: Respect nested .gitignore files in search_files (#7921 by @hannesrudolph, PR by @daniel-lxs) - Fix: Preserve trailing newlines in stripLineNumbers for apply_diff (#8020 by @liyi3c, PR by @app/roomote) - Fix: Exclude max tokens field for models that don't support it in export (#7944 by @hannesrudolph, PR by @elianiva) @@ -840,7 +1257,7 @@ - UX: Responsive Auto-Approve (thanks @brunobergher!) - Add telemetry retry queue for network resilience (thanks @daniel-lxs!) - Fix: Transform keybindings in nightly build to fix command+y shortcut (thanks @app/roomote!) -- New code-supernova stealth model in the Roo Code Cloud provider (thanks @mrubens!) +- New code-supernova stealth model in the Roo Code Router (thanks @mrubens!) ## [3.28.3] - 2025-09-16 @@ -857,7 +1274,6 @@ - Reposition Add Image button inside ChatTextArea (thanks @roomote!) - Bring back a way to temporarily and globally pause auto-approve without losing your toggle state (thanks @brunobergher!) - Makes text area buttons appear only when there's text (thanks @brunobergher!) -- CONTRIBUTING.md tweaks and issue template rewrite (thanks @hannesrudolph!) - Bump axios from 1.9.0 to 1.12.0 (thanks @dependabot!) ## [3.28.2] - 2025-09-14 @@ -1051,11 +1467,11 @@ ## [3.25.19] - 2025-08-19 -- Fix issue where new users couldn't select the Roo Code Cloud provider (thanks @daniel-lxs!) +- Fix issue where new users couldn't select the Roo Code Router (thanks @daniel-lxs!) ## [3.25.18] - 2025-08-19 -- Add new stealth Sonic model through the Roo Code Cloud provider +- Add new stealth Sonic model through the Roo Code Router - Fix: respect enableReasoningEffort setting when determining reasoning usage (#7048 by @ikbencasdoei, PR by @app/roomote) - Fix: prevent duplicate LM Studio models with case-insensitive deduplication (#6954 by @fbuechler, PR by @daniel-lxs) - Feat: simplify ask_followup_question prompt documentation (thanks @daniel-lxs!) @@ -1298,7 +1714,7 @@ - Add: Mistral embedding provider (thanks @SannidhyaSah!) - Fix: add run parameter to vitest command in rules (thanks @KJ7LNW!) - Update: the max_tokens fallback logic in the sliding window -- Fix: Bedrock and Vertext token counting improvements (thanks @daniel-lxs!) +- Fix: Bedrock and Vertex token counting improvements (thanks @daniel-lxs!) - Add: llama-4-maverick model to Vertex AI provider (thanks @MuriloFP!) - Fix: properly distinguish between user cancellations and API failures - Fix: add case sensitivity mention to suggested fixes in apply_diff error message @@ -1317,7 +1733,6 @@ - Fix Claude model detection by name for API protocol selection (thanks @daniel-lxs!) - Move marketplace icon from overflow menu to top navigation - Optional setting to prevent completion with open todos -- Added YouTube to website footer (thanks @thill2323!) ## [3.23.14] - 2025-07-17 @@ -1608,7 +2023,7 @@ - Sync BatchDiffApproval styling with BatchFilePermission for UI consistency (thanks @samhvw8!) - Add max height constraint to MCP execution response for better UX (thanks @samhvw8!) - Prevent MCP 'installed' label from being squeezed #4630 (thanks @daniel-lxs!) -- Allow a lower context condesning threshold (thanks @SECKainersdorfer!) +- Allow a lower context condensing threshold (thanks @SECKainersdorfer!) - Avoid type system duplication for cleaner codebase (thanks @EamonNerbonne!) ## [3.20.1] - 2025-06-12 @@ -1711,7 +2126,6 @@ - Fix bug with context condensing in Amazon Bedrock - Fix UTF-8 encoding in ExecaTerminalProcess (thanks @mr-ryan-james!) - Set sidebar name bugfix (thanks @chrarnoldus!) -- Fix link to CONTRIBUTING.md in feature request template (thanks @cannuri!) - Add task metadata to Unbound and improve caching logic (thanks @pugazhendhi-m!) ## [3.19.0] - 2025-05-29 @@ -1765,7 +2179,7 @@ ## [3.18.2] - 2025-05-23 -- Fix vscode-material-icons in the filer picker +- Fix vscode-material-icons in the file picker - Fix global settings export - Respect user-configured terminal integration timeout (thanks @KJ7LNW) - Context condensing enhancements (thanks @SannidhyaSah) @@ -1883,7 +2297,7 @@ - Add vertical tab navigation to the settings (thanks @dlab-anton) - Add Groq and Chutes API providers (thanks @shariqriazz) - Clickable code references in code block (thanks @KJ7LNW) -- Improve accessibility of ato-approve toggles (thanks @Deon588) +- Improve accessibility of auto-approve toggles (thanks @Deon588) - Requesty provider fixes (thanks @dtrugman) - Fix migration and persistence of per-mode API profiles (thanks @alasano) - Fix usage of `path.basename` in the extension webview (thanks @samhvw8) @@ -1945,7 +2359,7 @@ - Fix file mentions for filenames containing spaces - Improve the auto-approve toggle buttons for some high-contrast VSCode themes - Offload expensive count token operations to a web worker (thanks @samhvw8) -- Improve support for mult-root workspaces (thanks @snoyiatk) +- Improve support for multi-root workspaces (thanks @snoyiatk) - Simplify and streamline Roo Code's quick actions - Allow Roo Code settings to be imported from the welcome screen (thanks @julionav) - Remove unused types (thanks @wkordalski) @@ -2351,7 +2765,7 @@ - Custom ARNs in Amazon Bedrock (thanks @Smartsheet-JB-Brown!) - Update MCP servers directory path for platform compatibility (thanks @hannesrudolph!) - Fix browser system prompt inclusion rules (thanks @cannuri!) -- Publish git tags to github from CI (thanks @pdecat!) +- Publish git tags to GitHub from CI (thanks @pdecat!) - Fixes to OpenAI-style cost calculations (thanks @dtrugman!) - Fix to allow using an excluded directory as your working directory (thanks @Szpadel!) - Kotlin language support in list_code_definition_names tool (thanks @kohii!) @@ -2456,7 +2870,7 @@ ## [3.7.6] - 2025-02-26 -- Handle really long text better in the in the ChatRow similar to TaskHeader (thanks @joemanley201!) +- Handle really long text better in the ChatRow similar to TaskHeader (thanks @joemanley201!) - Support multiple files in drag-and-drop - Truncate search_file output to avoid crashing the extension - Better OpenRouter error handling (no more "Provider Error") @@ -2679,7 +3093,6 @@ - Ask and Architect modes can now edit markdown files - Custom modes can now be restricted to specific file patterns (for example, a technical writer who can only edit markdown files 👋) - Support for configuring the Bedrock provider with AWS Profiles -- New Roo Code community Discord at https://roocode.com/discord! ## [3.2.8] @@ -2719,8 +3132,6 @@ - Create specialized assistants for any workflow - Just type "Create a new mode for " or visit the Prompts tab in the top menu to get started -Join us at https://www.reddit.com/r/RooCode to share your custom modes and be part of our next chapter! - ## [3.1.7] - DeepSeek-R1 support (thanks @philipnext!) @@ -2768,12 +3179,8 @@ Join us at https://www.reddit.com/r/RooCode to share your custom modes and be pa ## [3.0.1] -- Fix the reddit link and a small visual glitch in the chat input - ## [3.0.0] -- This release adds chat modes! Now you can ask Roo Code questions about system architecture or the codebase without immediately jumping into writing code. You can even assign different API configuration profiles to each mode if you prefer to use different models for thinking vs coding. Would love feedback in the new Roo Code Reddit! https://www.reddit.com/r/RooCode - ## [2.2.46] - Only parse @-mentions in user input (not in files) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index fee05d72258..00000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,90 +0,0 @@ -
- - -English • [Català](locales/ca/CODE_OF_CONDUCT.md) • [Deutsch](locales/de/CODE_OF_CONDUCT.md) • [Español](locales/es/CODE_OF_CONDUCT.md) • [Français](locales/fr/CODE_OF_CONDUCT.md) • [हिंदी](locales/hi/CODE_OF_CONDUCT.md) • [Bahasa Indonesia](locales/id/CODE_OF_CONDUCT.md) • [Italiano](locales/it/CODE_OF_CONDUCT.md) • [日本語](locales/ja/CODE_OF_CONDUCT.md) - - - - -[한국어](locales/ko/CODE_OF_CONDUCT.md) • [Nederlands](locales/nl/CODE_OF_CONDUCT.md) • [Polski](locales/pl/CODE_OF_CONDUCT.md) • [Português (BR)](locales/pt-BR/CODE_OF_CONDUCT.md) • [Русский](locales/ru/CODE_OF_CONDUCT.md) • [Türkçe](locales/tr/CODE_OF_CONDUCT.md) • [Tiếng Việt](locales/vi/CODE_OF_CONDUCT.md) • [简体中文](locales/zh-CN/CODE_OF_CONDUCT.md) • [繁體中文](locales/zh-TW/CODE_OF_CONDUCT.md) - - -
- -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -- Using welcoming and inclusive language -- Being respectful of differing viewpoints and experiences -- Gracefully accepting constructive criticism -- Focusing on what is best for the community -- Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -- The use of sexualized language or imagery and unwelcome sexual attention or - advances -- Trolling, insulting/derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or electronic - address, without explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at support@roocode.com. All complaints -will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from [Cline's version][cline_coc] of the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[cline_coc]: https://github.com/cline/cline/blob/main/CODE_OF_CONDUCT.md -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 869b59a16da..00000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,141 +0,0 @@ -
- - -English • [Català](locales/ca/CONTRIBUTING.md) • [Deutsch](locales/de/CONTRIBUTING.md) • [Español](locales/es/CONTRIBUTING.md) • [Français](locales/fr/CONTRIBUTING.md) • [हिंदी](locales/hi/CONTRIBUTING.md) • [Bahasa Indonesia](locales/id/CONTRIBUTING.md) • [Italiano](locales/it/CONTRIBUTING.md) • [日本語](locales/ja/CONTRIBUTING.md) - - - - -[한국어](locales/ko/CONTRIBUTING.md) • [Nederlands](locales/nl/CONTRIBUTING.md) • [Polski](locales/pl/CONTRIBUTING.md) • [Português (BR)](locales/pt-BR/CONTRIBUTING.md) • [Русский](locales/ru/CONTRIBUTING.md) • [Türkçe](locales/tr/CONTRIBUTING.md) • [Tiếng Việt](locales/vi/CONTRIBUTING.md) • [简体中文](locales/zh-CN/CONTRIBUTING.md) • [繁體中文](locales/zh-TW/CONTRIBUTING.md) - - -
- -# Contributing to Roo Code - -Roo Code is a community-driven project, and we deeply value every contribution. To streamline collaboration, we operate on an [Issue-First](#issue-first-approach) basis, meaning all [Pull Requests (PRs)](#submitting-a-pull-request) must first be linked to a GitHub Issue. Please review this guide carefully. - -## Table of Contents - -- [Before You Contribute](#before-you-contribute) -- [Finding & Planning Your Contribution](#finding--planning-your-contribution) -- [Development & Submission Process](#development--submission-process) -- [Legal](#legal) - -## Before You Contribute - -### 1. Code of Conduct - -All contributors must adhere to our [Code of Conduct](./CODE_OF_CONDUCT.md). - -### 2. Project Roadmap - -Our roadmap guides the project's direction. Align your contributions with these key goals: - -### Reliability First - -- Ensure diff editing and command execution are consistently reliable. -- Reduce friction points that deter regular usage. -- Guarantee smooth operation across all locales and platforms. -- Expand robust support for a wide variety of AI providers and models. - -### Enhanced User Experience - -- Streamline the UI/UX for clarity and intuitiveness. -- Continuously improve the workflow to meet the high expectations developers have for daily-use tools. - -### Leading on Agent Performance - -- Establish comprehensive evaluation benchmarks (evals) to measure real-world productivity. -- Make it easy for everyone to easily run and interpret these evals. -- Ship improvements that demonstrate clear increases in eval scores. - -Mention alignment with these areas in your PRs. - -### 3. Join the Roo Code Community - -- **Primary:** Join our [Discord](https://discord.gg/roocode) and DM **Hannes Rudolph (`hrudolph`)**. -- **Alternative:** Experienced contributors can engage directly via [GitHub Projects](https://github.com/orgs/RooCodeInc/projects/1). - -## Finding & Planning Your Contribution - -### Types of Contributions - -- **Bug Fixes:** Addressing code issues. -- **New Features:** Adding functionality. -- **Documentation:** Improving guides and clarity. - -### Issue-First Approach - -All contributions start with a GitHub Issue using our skinny templates. - -- **Check existing issues**: Search [GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues). -- **Create an issue** using: - - **Enhancements:** "Enhancement Request" template (plain language focused on user benefit). - - **Bugs:** "Bug Report" template (minimal repro + expected vs actual + version). -- **Want to work on it?** Comment "Claiming" on the issue and DM **Hannes Rudolph (`hrudolph`)** on [Discord](https://discord.gg/roocode) to get assigned. Assignment will be confirmed in the thread. -- **PRs must link to the issue.** Unlinked PRs may be closed. - -### Deciding What to Work On - -- Check the [GitHub Project](https://github.com/orgs/RooCodeInc/projects/1) for "Issue [Unassigned]" issues. -- For docs, visit [Roo Code Docs](https://github.com/RooCodeInc/Roo-Code-Docs). - -### Reporting Bugs - -- Check for existing reports first. -- Create a new bug using the ["Bug Report" template](https://github.com/RooCodeInc/Roo-Code/issues/new/choose) with: - - Clear, numbered reproduction steps - - Expected vs actual result - - Roo Code version (required); API provider/model if relevant -- **Security issues**: Report privately via [security advisories](https://github.com/RooCodeInc/Roo-Code/security/advisories/new). - -## Development & Submission Process - -### Development Setup - -1. **Fork & Clone:** - -``` -git clone https://github.com/YOUR_USERNAME/Roo-Code.git -``` - -2. **Install Dependencies:** - -``` -pnpm install -``` - -3. **Debugging:** Open with VS Code (`F5`). - -### Writing Code Guidelines - -- One focused PR per feature or fix. -- Follow ESLint and TypeScript best practices. -- Write clear, descriptive commits referencing issues (e.g., `Fixes #123`). -- Provide thorough testing (`npm test`). -- Rebase onto the latest `main` branch before submission. - -### Submitting a Pull Request - -- Begin as a **Draft PR** if seeking early feedback. -- Clearly describe your changes following the Pull Request Template. -- Link the issue in the PR description/title (e.g., "Fixes #123"). -- Provide screenshots/videos for UI changes. -- Indicate if documentation updates are necessary. - -### Pull Request Policy - -- Must reference an assigned GitHub Issue. To get assigned: comment "Claiming" on the issue and DM **Hannes Rudolph (`hrudolph`)** on [Discord](https://discord.gg/roocode). Assignment will be confirmed in the thread. -- Unlinked PRs may be closed. -- PRs should pass CI tests, align with the roadmap, and have clear documentation. - -### Review Process - -- **Daily Triage:** Quick checks by maintainers. -- **Weekly In-depth Review:** Comprehensive assessment. -- **Iterate promptly** based on feedback. - -## Legal - -By contributing, you agree your contributions will be licensed under the Apache 2.0 License, consistent with Roo Code's licensing. diff --git a/PRIVACY.md b/PRIVACY.md index 02e8e151034..353b8ae5606 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -6,12 +6,11 @@ Roo Code respects your privacy and is committed to transparency about how we han ### **Where Your Data Goes (And Where It Doesn’t)** -- **Code & Files**: Roo Code accesses files on your local machine when needed for AI-assisted features. When you send commands to Roo Code, relevant files may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. If you select Roo Code Cloud as the model provider (proxy mode), your code may transit Roo Code servers only to forward it to the upstream provider. We do not store your code; it is deleted immediately after forwarding. Otherwise, your code is sent directly to the provider. AI providers may store data per their privacy policies. +- **Code & Files**: Roo Code accesses files on your local machine when needed for AI-assisted features. When you send commands to Roo Code, relevant files may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. AI providers may store data per their privacy policies. - **Commands**: Any commands executed through Roo Code happen on your local environment. However, when you use AI-powered features, the relevant code and context from your commands may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not have access to or store this data, but AI providers may process it per their privacy policies. -- **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. If you choose Roo Code Cloud as the provider (proxy mode), prompts may transit Roo Code servers only to forward them to the upstream model and are not stored. +- **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. - **Telemetry (Usage Data)**: We collect anonymous feature usage and error data to help us improve Roo Code. This telemetry is powered by PostHog and includes your VS Code machine ID, feature usage patterns, and exception reports. This telemetry does **not** collect personally identifiable information, your code, or AI prompts. You can opt out of this telemetry at any time through the settings. -- **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Roo Code makes a secure API call to Roo Code's backend servers to retrieve listing information. These requests send only the query parameters (e.g., extension version, search term) necessary to fulfill the request and do not include your code, prompts, or personally identifiable information. ### **How We Use Your Data (If Collected)** diff --git a/README.md b/README.md index 75f37762f93..718d9f15921 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,5 @@

- VS Code Marketplace - X - YouTube - Join Discord - Join r/RooCode -

-

- Get help fast → Join Discord • Prefer async? → Join r/RooCode + VS Code Marketplace

# Roo Code @@ -58,119 +51,27 @@ Roo Code adapts to how you work: - Ask Mode: fast answers, explanations, and docs - Debug Mode: trace issues, add logs, isolate root causes - Custom Modes: build specialized modes for your team or workflow -- Roomote Control: Roomote Control lets you remotely control tasks running in your local VS Code instance. - -Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) -## Tutorial & Feature Videos - -
- -| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | -|
Custom Modes |
Checkpoints |
Context Management | - -
-

-More quick tutorial and feature videos... -

+Learn more: [Using Modes](https://roocodeinc.github.io/Roo-Code/basic-usage/using-modes) • [Custom Modes](https://roocodeinc.github.io/Roo-Code/advanced-usage/custom-modes) ## Resources -- **[Documentation](https://docs.roocode.com):** The official guide to installing, configuring, and mastering Roo Code. -- **[YouTube Channel](https://youtube.com/@roocodeyt?feature=shared):** Watch tutorials and see features in action. -- **[Discord Server](https://discord.gg/roocode):** Join the community for real-time help and discussion. -- **[Reddit Community](https://www.reddit.com/r/RooCode):** Share your experiences and see what others are building. +- **[Documentation](https://roocodeinc.github.io/Roo-Code/):** The official guide to installing, configuring, and mastering Roo Code. - **[GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues):** Report bugs and track development. -- **[Feature Requests](https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop):** Have an idea? Share it with the developers. - ---- - -## Local Setup & Development - -1. **Clone** the repo: - -```sh -git clone https://github.com/RooCodeInc/Roo-Code.git -``` - -2. **Install dependencies**: - -```sh -pnpm install -``` - -3. **Run the extension**: - -There are several ways to run the Roo Code extension: - -### Development Mode (F5) - -For active development, use VSCode's built-in debugging: - -Press `F5` (or go to **Run** → **Start Debugging**) in VSCode. This will open a new VSCode window with the Roo Code extension running. - -- Changes to the webview will appear immediately. -- Changes to the core extension will also hot reload automatically. - -### Automated VSIX Installation - -To build and install the extension as a VSIX package directly into VSCode: - -```sh -pnpm install:vsix [-y] [--editor=] -``` - -This command will: - -- Ask which editor command to use (code/cursor/code-insiders) - defaults to 'code' -- Uninstall any existing version of the extension. -- Build the latest VSIX package. -- Install the newly built VSIX. -- Prompt you to restart VS Code for changes to take effect. - -Options: - -- `-y`: Skip all confirmation prompts and use defaults -- `--editor=`: Specify the editor command (e.g., `--editor=cursor` or `--editor=code-insiders`) - -### Manual VSIX Installation - -If you prefer to install the VSIX package manually: - -1. First, build the VSIX package: - ```sh - pnpm vsix - ``` -2. A `.vsix` file will be generated in the `bin/` directory (e.g., `bin/roo-cline-.vsix`). -3. Install it manually using the VSCode CLI: - ```sh - code --install-extension bin/roo-cline-.vsix - ``` - ---- - -We use [changesets](https://github.com/changesets/changesets) for versioning and publishing. Check our `CHANGELOG.md` for release notes. --- ## Disclaimer -**Please note** that Roo Code, Inc does **not** make any representations or warranties regarding any code, models, or other tools provided or made available in connection with Roo Code, any associated third-party tools, or any resulting outputs. You assume **all risks** associated with the use of any such tools or outputs; such tools are provided on an **"AS IS"** and **"AS AVAILABLE"** basis. Such risks may include, without limitation, intellectual property infringement, cyber vulnerabilities or attacks, bias, inaccuracies, errors, defects, viruses, downtime, property loss or damage, and/or personal injury. You are solely responsible for your use of any such tools or outputs (including, without limitation, the legality, appropriateness, and results thereof). - ---- +The Roo Code Extension was shut down on May 15th. -## Contributing +- If you're looking for an alternative, check out [ZooCode](https://github.com/Zoo-Code-Org/Zoo-Code/) (a fork started by the Roo Code community) and [Cline](https://cline.bot/) (from where Roo Code originated). +- If you were a paying user and have billing questions, please write [billing@roocode.com](mailto:billing@roocode.com). -We love community contributions! Get started by reading our [CONTRIBUTING.md](CONTRIBUTING.md). +**Please note** that Roo Code, Inc does **not** make any representations or warranties regarding any code, models, or other tools provided or made available in connection with Roo Code, any associated third-party tools, or any resulting outputs. You assume **all risks** associated with the use of any such tools or outputs; such tools are provided on an **"AS IS"** and **"AS AVAILABLE"** basis. Such risks may include, without limitation, intellectual property infringement, cyber vulnerabilities or attacks, bias, inaccuracies, errors, defects, viruses, downtime, property loss or damage, and/or personal injury. You are solely responsible for your use of any such tools or outputs (including, without limitation, the legality, appropriateness, and results thereof). --- ## License -[Apache 2.0 © 2025 Roo Code, Inc.](./LICENSE) - ---- - -**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can’t wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding! +[Apache 2.0 © 2026 Roo Code, Inc.](./LICENSE) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md new file mode 100644 index 00000000000..45476e0e245 --- /dev/null +++ b/apps/cli/CHANGELOG.md @@ -0,0 +1,391 @@ +# Changelog + +All notable changes to the `@roo-code/cli` package will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.17] - 2026-03-04 + +### Added + +- **Custom Session ID Support**: New `--create-with-session-id` flag allows specifying a custom UUID session ID when creating tasks. Session IDs are now validated as UUIDs for both create and resume operations, as well as for `start.taskId` in stdin-stream mode. + +### Tests + +- Added integration coverage for create+resume loading the correct session. + +## [0.1.16] - 2026-03-04 + +### Added + +- **Custom Shell Selection**: New `--terminal-shell` flag to specify which shell to use for inline command execution. The shell path is validated at the CLI layer and passed through the standard settings mechanism. + +### Tests + +- Added integration coverage for stdin stream routing and race invariants. + +## [0.1.15] - 2026-03-03 + +### Fixed + +- **Follow-up Routing for Completion Asks**: Fixed routing of follow-up messages when the agent asks for clarification (ask_followup_question) in stdin-stream mode. Messages sent after a completion ask are now correctly delivered to the agent instead of being queued. + +## [0.1.14] - 2026-03-03 + +### Fixed + +- **Command Output Streaming**: Ensure full command output is streamed before the done event is emitted, preventing truncated output in stdin-stream mode. + +## [0.1.13] - 2026-03-02 + +### Added + +- **Skills as Slash Commands**: Skills are now exposed as slash commands, so you can invoke skill workflows directly from command-style input. +- **Skill Fallback Execution**: When a slash command does not match a command file but matches a skill slug, the CLI can resolve and execute that skill path. + +### Changed + +- **Slash Command Resolution Priority**: Command precedence is preserved, with skill fallback only used when no matching slash command is found. + +### Tests + +- Added and updated tests for slash command + skill fallback behavior, including command precedence and duplicate skill-slug handling. + +## [0.1.12] - 2026-03-02 + +### Fixed + +- **Command Timeout Handling**: CLI runtime now correctly ignores model-provided background timeouts for commands, ensuring command lifetime is governed solely by the `--timeout` setting. + +## [0.1.11] - 2026-03-02 + +### Added + +- **Image Support in Stdin Stream**: The `start` and `message` commands in stdin-stream mode now support an optional `images` field (array of base64 data URIs) to attach images to prompts. + +### Fixed + +- **Upgrade Version Detection**: Fixed version detection in the `upgrade` command to correctly identify when updates are available. + +## [0.1.10] - 2026-03-02 + +### Added + +- **Command Exit Code in Events**: The `tool_result` event for command executions now includes an `exitCode` field, allowing CLI consumers to programmatically distinguish between successful and failed command executions without parsing output text. + +## [0.1.9] - 2026-03-02 + +### Fixed + +- **Stdin Stream Cancel Race**: Fixed a race condition during startup cancellation in stdin-stream mode that could cause unexpected behavior when canceling tasks immediately after starting them. + +### Tests + +- **Integration Test Suite**: Added comprehensive integration test suite for stdin-stream protocol covering cancel, followup, multi-message queue, and shutdown scenarios. + +## [0.1.8] - 2026-03-02 + +### Changed + +- **Command Execution Timeout**: Increased timeout for command execution to improve reliability for long-running operations. + +### Fixed + +- **Stdin Stream Queue Handling**: Fixed stdin stream queued messages and command output streaming to ensure messages are properly processed. + +## [0.1.7] - 2026-03-01 + +### Fixed + +- **Stdin Stream Control Flow**: Gracefully handle control-flow errors in stdin-stream mode to prevent unexpected crashes during cancellation and shutdown sequences. + +### Changed + +- **Type Definitions**: Refactored and simplified JSON event type definitions for better type safety. + +## [0.1.6] - 2026-02-27 + +### Added + +- **Consecutive Mistake Limit**: New `--mistake-limit` flag to configure the maximum number of consecutive mistakes before the agent pauses for intervention. + +### Changed + +- **Workspace-Scoped Sessions**: The `list sessions` command and `--resume` flag now only show and resume sessions from the current workspace directory. + +### Fixed + +- **Task Configuration Forwarding**: Task configuration (custom modes, disabled tools, etc.) passed via the stdin-prompt-stream protocol is now correctly forwarded to the extension host instead of being silently dropped. +- **Stream Error Recovery**: Improved recovery from streaming errors to prevent task interruption. + +## [0.1.5] - 2026-02-26 + +### Added + +- **Session History**: New `list sessions` subcommand to view recent CLI sessions with task IDs, timestamps, and initial prompts. +- **Session Resume**: New `--resume ` flag to continue a previous session from where it left off. +- **Upgrade Command**: New `upgrade` command to check for and install the latest CLI version. + +## [0.1.4] - 2026-02-26 + +### Fixed + +- **Exception Handling**: Improved recovery from unhandled exceptions in the CLI to prevent unexpected crashes. + +## [0.1.3] - 2026-02-25 + +### Fixed + +- **Task Resumption**: Fixed an issue where resuming a previously suspended task could fail due to state initialization timing in the extension host. + +## [0.1.2] - 2026-02-25 + +### Changed + +- **Streaming Deltas**: Tool use ask messages (command, tool, mcp) are now streamed as structured deltas instead of full snapshots in json-event-emitter for improved efficiency. +- **Task ID Propagation**: Task ID is now generated upfront and propagated through runTask/createTask so currentTaskId is available in extension state immediately. +- **Custom Tools**: Enabled customTools experiment in extension host. + +### Fixed + +- **Cancel Recovery**: Wait for resumable state after cancel before processing follow-up messages to prevent race conditions in stdin-stream. +- **Custom Tool Schema**: Provide valid empty JSON Schema for custom tools without parameters to fix strict-mode API validation. +- **Path Handling**: Skip paths outside cwd in RooProtectedController to avoid RangeError. +- **Retry Handling**: Silently handle abort during exponential backoff retry countdown. +- Fixed spelling/grammar and casing inconsistencies. + +### Added + +- **Telemetry Control**: Added `ROO_CODE_DISABLE_TELEMETRY=1` environment variable to disable cloud telemetry. + +## [0.1.1] - 2026-02-24 + +### Added + +- **Roo Model Warmup**: When configured with the Roo provider, the CLI now proactively fetches and warms the model list during activation so that model information is available before the first prompt is sent. The warmup has a 10s timeout and failures are logged only in debug mode. +- **Unbound Provider**: Added Unbound as an available provider option. + +## [0.1.0] - 2026-02-19 + +### Added + +- **NDJSON Stdin Protocol**: Overhauled the stdin prompt stream from raw text lines to a structured NDJSON command protocol (`start`/`message`/`cancel`/`ping`/`shutdown`) with requestId correlation, ack/done/error lifecycle events, and queue telemetry. See [`stdin-stream.ts`](src/ui/stdin-stream.ts) for implementation. +- **List Subcommands**: New `list` subcommands (`commands`, `modes`, `models`) for programmatic discovery of available CLI capabilities. +- **Shared Utilities**: Added `isRecord` guard utility for improved type safety. + +### Changed + +- **Modularized Architecture**: Extracted stdin stream logic from `run.ts` into dedicated [`stdin-stream.ts`](src/ui/stdin-stream.ts) module for better code organization and maintainability. + +### Fixed + +- Fixed a bug in `Task.ts` affecting CLI operation. + +## [0.0.55] - 2026-02-17 + +### Fixed + +- **Stdin Stream Mode**: Fixed issue where new tasks were incorrectly being created in stdin-prompt-stream mode. The mode now properly reuses the existing task for subsequent prompts instead of creating new tasks. + +## [0.0.54] - 2026-02-15 + +### Added + +- **Stdin Stream Mode**: New `stdin-prompt-stream` mode that reads prompts from stdin, allowing batch processing and piping multiple tasks. Each line of stdin is processed as a separate prompt with streaming JSON output. See [`stdin-prompt-stream.ts`](src/ui/stdin-prompt-stream.ts) for implementation. + +### Fixed + +- Fixed JSON emitter state not being cleared between tasks in stdin-prompt-stream mode +- Fixed inconsistent user role for prompt echo partials in stream-json mode + +## [0.0.53] - 2026-02-12 + +### Changed + +- **Auto-Approve by Default**: The CLI now auto-approves all actions (tools, commands, browser, MCP) by default. Followup questions auto-select the first suggestion after a 60-second timeout. +- **New `--require-approval` Flag**: Replaced `-y`/`--yes`/`--dangerously-skip-permissions` flags with a new `-a, --require-approval` flag for users who want manual approval prompts before actions execute. + +### Fixed + +- Spamming the escape key to cancel a running task no longer crashes the cli. + +## [0.0.52] - 2026-02-09 + +### Added + +- **Linux Support**: Added support for `linux-arm64`. + +## [0.0.51] - 2026-02-06 + +### Changed + +- **Default Model Update**: Changed the default model from Opus 4.5 to Opus 4.6 for improved performance and capabilities + +## [0.0.50] - 2026-02-05 + +### Added + +- **Linux Support**: The CLI now supports Linux platforms in addition to macOS +- **Roo Provider API Key Support**: Allow `--api-key` flag and `ROO_API_KEY` environment variable for the roo provider instead of requiring cloud auth token +- **Exit on Error**: New `--exit-on-error` flag to exit immediately on API request errors instead of retrying, useful for CI/CD pipelines + +### Changed + +- **Improved Dev Experience**: Dev scripts now use `tsx` for running directly from source without building first +- **Path Resolution Fixes**: Fixed path resolution in [`version.ts`](src/lib/utils/version.ts), [`extension.ts`](src/lib/utils/extension.ts), and [`extension-host.ts`](src/agent/extension-host.ts) to work from both source and bundled locations +- **Debug Logging**: Debug log file (`~/.roo/cli-debug.log`) is now disabled by default unless `--debug` flag is passed +- Updated README with complete environment variable table and dev workflow documentation + +### Fixed + +- Corrected example in install script + +### Removed + +- Dropped macOS 13 support + +## [0.0.49] - 2026-01-18 + +### Added + +- **Output Format Options**: New `--output-format` flag to control CLI output format for scripting and automation: + - `text` (default) - Human-readable interactive output + - `json` - Single JSON object with all events and final result at task completion + - `stream-json` - NDJSON (newline-delimited JSON) for real-time streaming of events + - See [`json-events.ts`](src/types/json-events.ts) for the complete event schema + - New [`JsonEventEmitter`](src/agent/json-event-emitter.ts) for structured output generation + +## [0.0.48] - 2026-01-17 + +### Changed + +- Simplified authentication callback flow by using HTTP redirects instead of POST requests with CORS headers for improved browser compatibility + +## [0.0.47] - 2026-01-17 + +### Added + +- **Workspace flag**: New `-w, --workspace ` option to specify a custom workspace directory instead of using the current working directory +- **Oneshot mode**: New `--oneshot` flag to exit upon task completion, useful for scripting and automation (can also be saved in settings via [`CliSettings.oneshot`](src/types/types.ts)) + +### Changed + +- Skip onboarding flow when a provider is explicitly specified via `--provider` flag or saved in settings +- Unified permission flags: Combined approval-skipping flags into a single option for Claude Code-like CLI compatibility +- Improved Roo Code Router authentication flow and error messaging + +### Fixed + +- Removed unnecessary timeout that could cause issues with long-running tasks +- Fixed authentication token validation for Roo Code Router provider + +## [0.0.45] - 2026-01-08 + +### Changed + +- **Major Refactor**: Extracted ~1400 lines from [`App.tsx`](src/ui/App.tsx) into reusable hooks and utilities for better maintainability: + + - [`useExtensionHost`](src/ui/hooks/useExtensionHost.ts) - Extension host connection and lifecycle management + - [`useMessageHandlers`](src/ui/hooks/useMessageHandlers.ts) - Message processing and state updates + - [`useTaskSubmit`](src/ui/hooks/useTaskSubmit.ts) - Task submission logic + - [`useGlobalInput`](src/ui/hooks/useGlobalInput.ts) - Global keyboard shortcut handling + - [`useFollowupCountdown`](src/ui/hooks/useFollowupCountdown.ts) - Auto-approval countdown logic + - [`useFocusManagement`](src/ui/hooks/useFocusManagement.ts) - Input focus state management + - [`usePickerHandlers`](src/ui/hooks/usePickerHandlers.ts) - Picker component event handling + - [`uiStateStore`](src/ui/stores/uiStateStore.ts) - UI-specific state (showExitHint, countdown, etc.) + - Tool data utilities ([`extractToolData`](src/ui/utils/toolDataUtils.ts), `formatToolOutput`, etc.) + - [`HorizontalLine`](src/ui/components/HorizontalLine.tsx) component + +- **Performance Optimizations**: + + - Added RAF-style scroll throttling to reduce state updates + - Stabilized `useExtensionHost` hook return values with `useCallback`/`useMemo` + - Added streaming message debouncing to batch rapid partial updates + - Added shallow array equality checks to prevent unnecessary re-renders + +- Simplified [`ModeTool`](src/ui/components/tools/ModeTool.tsx) layout to horizontal with mode suffix +- Simplified logging by removing verbose debug output and adding first/last partial message logging pattern +- Updated Nerd Font icon codepoints in [`Icon`](src/ui/components/Icon.tsx) component + +### Added + +- `#` shortcut in help trigger for quick access to task history autocomplete + +### Fixed + +- Fixed a crash in message handling +- Added protected file warning in tool approval prompts +- Enabled `alwaysAllowWriteProtected` for non-interactive mode + +### Removed + +- Removed unused `renderLogger.ts` utility file + +### Tests + +- Updated extension-host tests to expect `[Tool Request]` format +- Updated Icon tests to expect single-char Nerd Font icons + +## [0.0.44] - 2026-01-08 + +### Added + +- **Tool Renderer Components**: Specialized renderers for displaying tool outputs with optimized formatting for each tool type. Each renderer provides a focused view of its data structure. + + - [`FileReadTool`](src/ui/components/tools/FileReadTool.tsx) - Display file read operations with syntax highlighting + - [`FileWriteTool`](src/ui/components/tools/FileWriteTool.tsx) - Show file write/edit operations with diff views + - [`SearchTool`](src/ui/components/tools/SearchTool.tsx) - Render search results with context + - [`CommandTool`](src/ui/components/tools/CommandTool.tsx) - Display command execution with output + - [`BrowserTool`](src/ui/components/tools/BrowserTool.tsx) - Show browser automation actions + - [`ModeTool`](src/ui/components/tools/ModeTool.tsx) - Display mode switching operations + - [`CompletionTool`](src/ui/components/tools/CompletionTool.tsx) - Show task completion status + - [`GenericTool`](src/ui/components/tools/GenericTool.tsx) - Fallback renderer for other tools + +- **History Trigger**: New `#` trigger for task history autocomplete with fuzzy search support. Type `#` at the start of a line to browse and resume previous tasks. + + - [`HistoryTrigger.tsx`](src/ui/components/autocomplete/triggers/HistoryTrigger.tsx) - Trigger implementation with fuzzy filtering + - Shows task status, mode, and relative timestamps + - Supports keyboard navigation for quick task selection + +- **Release Confirmation Prompt**: The release script now prompts for confirmation before creating a release. + +### Fixed + +- Task history picker selection and navigation issues +- Mode switcher keyboard handling bug + +### Changed + +- Reorganized test files into `__tests__` directories for better project structure +- Refactored utility modules into dedicated `utils/` directory + +## [0.0.43] - 2026-01-07 + +### Added + +- **Toast Notification System**: New toast notifications for user feedback with support for info, success, warning, and error types. Toasts auto-dismiss after a configurable duration and are managed via Zustand store. + + - New [`ToastDisplay`](src/ui/components/ToastDisplay.tsx) component for rendering toast messages + - New [`useToast`](src/ui/hooks/useToast.ts) hook for managing toast state and displaying notifications + +- **Global Input Sequences Registry**: Centralized system for handling keyboard shortcuts at the application level, preventing conflicts with input components. + + - New [`globalInputSequences.ts`](src/ui/utils/globalInputSequences.ts) utility module + - Support for Kitty keyboard protocol (CSI u encoding) for better terminal compatibility + - Built-in sequences for `Ctrl+C` (exit) and `Ctrl+M` (mode cycling) + +- **Local Tarball Installation**: The install script now supports installing from a local tarball via the `ROO_LOCAL_TARBALL` environment variable, useful for offline installation or testing pre-release builds. + +### Changed + +- **MultilineTextInput**: Updated to respect global input sequences, preventing the component from consuming shortcuts meant for application-level handling. + +### Tests + +- Added comprehensive tests for the toast notification system +- Added tests for global input sequence matching + +## [0.0.42] - 2025-01-07 + +The cli is alive! diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 00000000000..6c9b73fa697 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,240 @@ +# @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 Apple Silicon (M1/M2/M3/M4) or Linux x64 + +**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 +``` + +Or run: + +```bash +roo upgrade +``` + +### Uninstalling + +```bash +rm -rf ~/.roo/cli ~/.local/bin/roo +``` + +## Usage + +### Interactive Mode (Default) + +By default, the CLI auto-approves actions and runs in interactive TUI mode: + +```bash +export OPENROUTER_API_KEY=sk-or-v1-... + +roo "What is this project?" -w ~/Documents/my-project +``` + +You can also run without a prompt and enter it interactively in TUI mode: + +```bash +roo -w ~/Documents/my-project +``` + +In interactive mode: + +- Tool executions are auto-approved +- Commands are auto-approved +- Followup questions show suggestions with a 60-second timeout, then auto-select the first suggestion +- Browser and MCP actions are auto-approved + +### Approval-Required Mode (`--require-approval`) + +If you want manual approval prompts, enable approval-required mode: + +```bash +roo "Refactor the utils.ts file" --require-approval -w ~/Documents/my-project +``` + +In approval-required mode: + +- Tool, command, browser, and MCP actions prompt for yes/no approval +- Followup questions wait for manual input (no auto-timeout) + +### Print Mode (`--print`) + +Use `--print` for non-interactive execution and machine-readable output: + +```bash +# Prompt is required +roo --print "Summarize this repository" + +# Create a new task with a specific session ID (UUID) +roo --print --create-with-session-id 018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87 "Summarize this repository" +``` + +### Stdin Stream Mode (`--stdin-prompt-stream`) + +For programmatic control (one process, multiple prompts), use `--stdin-prompt-stream` with `--print`. +Send NDJSON commands via stdin: + +```bash +printf '{"command":"start","requestId":"1","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json + +# Optional: provide taskId per start command +printf '{"command":"start","requestId":"1","taskId":"018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json +``` + +## Options + +| Option | Description | Default | +| --------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------- | +| `[prompt]` | Your prompt (positional argument, optional) | None | +| `--prompt-file ` | Read prompt from a file instead of command line argument | None | +| `--create-with-session-id ` | Create a new task using the provided session ID (UUID) | None | +| `-w, --workspace ` | Workspace path to operate in | Current directory | +| `-p, --print` | Print response and exit (non-interactive mode) | `false` | +| `--stdin-prompt-stream` | Read NDJSON control commands from stdin (requires `--print`) | `false` | +| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | +| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | +| `-a, --require-approval` | Require manual approval before actions execute | `false` | +| `-k, --api-key ` | API key for the LLM provider | From env var | +| `--provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | +| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `--terminal-shell ` | Absolute shell path for inline terminal command execution | Auto-detected shell | +| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | +| `--consecutive-mistake-limit ` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` | +| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | +| `--oneshot` | Exit upon task completion | `false` | +| `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | + +## 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-native | `OPENAI_API_KEY` | +| openrouter | `OPENROUTER_API_KEY` | +| gemini | `GOOGLE_API_KEY` | +| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` | + +## 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", {...})` + +## Development + +```bash +# Run directly from source (no build required) +pnpm dev --provider openrouter --api-key $OPENROUTER_API_KEY --print "Hello" + +# Run tests +pnpm test + +# Type checking +pnpm check-types + +# Linting +pnpm lint +``` + +## Releasing + +Official releases are created via the GitHub Actions workflow at `.github/workflows/cli-release.yml`. + +To trigger a release: + +1. Go to **Actions** → **CLI Release** +2. Click **Run workflow** +3. Optionally specify a version (defaults to `package.json` version) +4. Click **Run workflow** + +The workflow will: + +1. Build the CLI on all platforms (macOS Apple Silicon, Linux x64) +2. Create platform-specific tarballs with bundled ripgrep +3. Verify each tarball +4. Create a GitHub release with all tarballs attached + +### Local Builds + +For local development and testing, use the build script: + +```bash +# Build tarball for your current platform +./apps/cli/scripts/build.sh + +# Build and install locally +./apps/cli/scripts/build.sh --install + +# Fast build (skip verification) +./apps/cli/scripts/build.sh --skip-verify +``` diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md new file mode 100644 index 00000000000..a512d47a500 --- /dev/null +++ b/apps/cli/docs/AGENT_LOOP.md @@ -0,0 +1,356 @@ +# CLI Agent Loop + +This document explains how the Roo Code CLI detects and tracks the agent loop state. + +## Overview + +The CLI needs to know when the agent is: + +- **Running** (actively processing) +- **Streaming** (receiving content from the API) +- **Waiting for input** (needs user approval or answer) +- **Idle** (task completed or failed) + +This is accomplished by analyzing the messages the extension sends to the client. + +## The Message Model + +All agent activity is communicated through **ClineMessages** - a stream of timestamped messages that represent everything the agent does. + +### Message Structure + +```typescript +interface ClineMessage { + ts: number // Unique timestamp identifier + type: "ask" | "say" // Message category + ask?: ClineAsk // Specific ask type (when type="ask") + say?: ClineSay // Specific say type (when type="say") + text?: string // Message content + partial?: boolean // Is this message still streaming? +} +``` + +### Two Types of Messages + +| Type | Purpose | Blocks Agent? | +| ------- | ---------------------------------------------- | ------------- | +| **say** | Informational - agent is telling you something | No | +| **ask** | Interactive - agent needs something from you | Usually yes | + +## The Key Insight + +> **The agent loop stops whenever the last message is an `ask` type (with `partial: false`).** + +The specific `ask` value tells you exactly what the agent needs. + +## Ask Categories + +The CLI categorizes asks into four groups: + +### 1. Interactive Asks → `WAITING_FOR_INPUT` state + +These require user action to continue: + +| Ask Type | What It Means | Required Response | +| ----------------------- | --------------------------------- | ----------------- | +| `tool` | Wants to edit/create/delete files | Approve or Reject | +| `command` | Wants to run a terminal command | Approve or Reject | +| `followup` | Asking a question | Text answer | +| `browser_action_launch` | Wants to use the browser | Approve or Reject | +| `use_mcp_server` | Wants to use an MCP server | Approve or Reject | + +### 2. Idle Asks → `IDLE` state + +These indicate the task has stopped: + +| Ask Type | What It Means | Response Options | +| ------------------------------- | --------------------------- | --------------------------- | +| `completion_result` | Task completed successfully | New task or feedback | +| `api_req_failed` | API request failed | Retry or new task | +| `mistake_limit_reached` | Too many errors | Continue anyway or new task | +| `auto_approval_max_req_reached` | Auto-approval limit hit | Continue manually or stop | +| `resume_completed_task` | Viewing completed task | New task | + +### 3. Resumable Asks → `RESUMABLE` state + +| Ask Type | What It Means | Response Options | +| ------------- | ------------------------- | ----------------- | +| `resume_task` | Task paused mid-execution | Resume or abandon | + +### 4. Non-Blocking Asks → `RUNNING` state + +| Ask Type | What It Means | Response Options | +| ---------------- | ------------------ | ----------------- | +| `command_output` | Command is running | Continue or abort | + +## Streaming Detection + +The agent is **streaming** when: + +1. **`partial: true`** on the last message, OR +2. **An `api_req_started` message exists** with `cost: undefined` in its text field + +```typescript +// Streaming detection pseudocode +function isStreaming(messages) { + const lastMessage = messages.at(-1) + + // Check partial flag (primary indicator) + if (lastMessage?.partial === true) { + return true + } + + // Check for in-progress API request + const apiReq = messages.findLast((m) => m.say === "api_req_started") + if (apiReq?.text) { + const data = JSON.parse(apiReq.text) + if (data.cost === undefined) { + return true // API request not yet complete + } + } + + return false +} +``` + +## State Machine + +``` + ┌─────────────────┐ + │ NO_TASK │ (no messages) + └────────┬────────┘ + │ newTask + ▼ + ┌─────────────────────────────┐ + ┌───▶│ RUNNING │◀───┐ + │ └──────────┬──────────────────┘ │ + │ │ │ + │ ┌──────────┼──────────────┐ │ + │ │ │ │ │ + │ ▼ ▼ ▼ │ + │ ┌──────┐ ┌─────────┐ ┌──────────┐ │ + │ │STREAM│ │WAITING_ │ │ IDLE │ │ + │ │ ING │ │FOR_INPUT│ │ │ │ + │ └──┬───┘ └────┬────┘ └────┬─────┘ │ + │ │ │ │ │ + │ │ done │ approved │ newTask │ + └────┴───────────┴────────────┘ │ + │ + ┌──────────────┐ │ + │ RESUMABLE │────────────────────────┘ + └──────────────┘ resumed +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ExtensionHost │ +│ │ +│ ┌──────────────────┐ │ +│ │ Extension │──── extensionWebviewMessage ─────┐ │ +│ │ (Task.ts) │ │ │ +│ └──────────────────┘ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ ExtensionClient │ │ +│ │ (Single Source of Truth) │ │ +│ │ │ │ +│ │ ┌─────────────────┐ ┌────────────────────┐ │ │ +│ │ │ MessageProcessor │───▶│ StateStore │ │ │ +│ │ │ │ │ (clineMessages) │ │ │ +│ │ └─────────────────┘ └────────┬───────────┘ │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ detectAgentState() │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Events: stateChange, message, waitingForInput, etc. │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ +│ │ OutputManager │ │ AskDispatcher │ │ PromptManager │ │ +│ │ (stdout) │ │ (ask routing) │ │ (user input) │ │ +│ └────────────────┘ └────────────────┘ └────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Component Responsibilities + +### ExtensionClient + +The **single source of truth** for agent state, including the current mode. It: + +- Receives all messages from the extension +- Stores them in the `StateStore` +- Tracks the current mode from state messages +- Computes the current state via `detectAgentState()` +- Emits events when state changes (including mode changes) + +```typescript +const client = new ExtensionClient({ + sendMessage: (msg) => extensionHost.sendToExtension(msg), + debug: true, // Writes to ~/.roo/cli-debug.log +}) + +// Query state at any time +const state = client.getAgentState() +if (state.isWaitingForInput) { + console.log(`Agent needs: ${state.currentAsk}`) +} + +// Query current mode +const mode = client.getCurrentMode() +console.log(`Current mode: ${mode}`) // e.g., "code", "architect", "ask" + +// Subscribe to events +client.on("waitingForInput", (event) => { + console.log(`Waiting for: ${event.ask}`) +}) + +// Subscribe to mode changes +client.on("modeChanged", (event) => { + console.log(`Mode changed: ${event.previousMode} -> ${event.currentMode}`) +}) +``` + +### StateStore + +Holds the `clineMessages` array, computed state, and current mode: + +```typescript +interface StoreState { + messages: ClineMessage[] // The raw message array + agentState: AgentStateInfo // Computed state + isInitialized: boolean // Have we received any state? + currentMode: string | undefined // Current mode (e.g., "code", "architect") +} +``` + +### MessageProcessor + +Handles incoming messages from the extension: + +- `"state"` messages → Update `clineMessages` array and track mode +- `"messageUpdated"` messages → Update single message in array +- Emits events for state transitions and mode changes + +### AskDispatcher + +Routes asks to appropriate handlers: + +- Uses type guards: `isIdleAsk()`, `isInteractiveAsk()`, etc. +- Coordinates between `OutputManager` and `PromptManager` +- By default, the CLI auto-approves tool/command/browser/MCP actions +- In `--require-approval` mode, those actions prompt for manual approval + +### OutputManager + +Handles all CLI output: + +- Streams partial content with delta computation +- Tracks what's been displayed to avoid duplicates +- Writes directly to `process.stdout` (bypasses quiet mode) + +### PromptManager + +Handles user input: + +- Yes/no prompts +- Text input prompts +- Timed prompts with auto-defaults + +## Response Messages + +When the agent is waiting, send these responses: + +```typescript +// Approve an action (tool, command, browser, MCP) +client.sendMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", +}) + +// Reject an action +client.sendMessage({ + type: "askResponse", + askResponse: "noButtonClicked", +}) + +// Answer a question +client.sendMessage({ + type: "askResponse", + askResponse: "messageResponse", + text: "My answer here", +}) + +// Start a new task +client.sendMessage({ + type: "newTask", + text: "Build a web app", +}) + +// Cancel current task +client.sendMessage({ + type: "cancelTask", +}) +``` + +## Type Guards + +The CLI uses type guards from `@roo-code/types` for categorization: + +```typescript +import { isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "@roo-code/types" + +const ask = message.ask +if (isInteractiveAsk(ask)) { + // Needs approval: tool, command, followup, etc. +} else if (isIdleAsk(ask)) { + // Task stopped: completion_result, api_req_failed, etc. +} else if (isResumableAsk(ask)) { + // Task paused: resume_task +} else if (isNonBlockingAsk(ask)) { + // Command running: command_output +} +``` + +## Debug Logging + +Enable with `-d` flag. Logs go to `~/.roo/cli-debug.log`: + +```bash +roo -d -P "Build something" --no-tui +``` + +View logs: + +```bash +tail -f ~/.roo/cli-debug.log +``` + +Example output: + +``` +[MessageProcessor] State update: { + "messageCount": 5, + "lastMessage": { + "msgType": "ask:completion_result" + }, + "stateTransition": "running → idle", + "currentAsk": "completion_result", + "isWaitingForInput": true +} +[MessageProcessor] EMIT waitingForInput: { "ask": "completion_result" } +[MessageProcessor] EMIT taskCompleted: { "success": true } +``` + +## Summary + +1. **Agent communicates via `ClineMessage` stream** +2. **Last message determines state** +3. **`ask` messages (non-partial) block the agent** +4. **Ask category determines required action** +5. **`partial: true` or `api_req_started` without cost = streaming** +6. **`ExtensionClient` is the single source of truth** diff --git a/packages/evals/eslint.config.mjs b/apps/cli/eslint.config.mjs similarity index 100% rename from packages/evals/eslint.config.mjs rename to apps/cli/eslint.config.mjs diff --git a/apps/cli/install.sh b/apps/cli/install.sh new file mode 100755 index 00000000000..6830eb535bb --- /dev/null +++ b/apps/cli/install.sh @@ -0,0 +1,353 @@ +#!/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) +# ROO_LOCAL_TARBALL - Path to local tarball to install (skips download) + +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() { + # Skip version fetch if using local tarball + if [ -n "$ROO_LOCAL_TARBALL" ]; then + VERSION="${ROO_VERSION:-local}" + info "Using local tarball (version: $VERSION)" + return + fi + + 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 highest cli-v* tag by semantic version (do not rely on API ordering) + VERSION=$(printf "%s" "$RELEASES_JSON" | node -e ' +const fs = require("fs") +const input = fs.readFileSync(0, "utf8") +let releases +try { + releases = JSON.parse(input) +} catch { + process.exit(1) +} + +function parseVersion(version) { + const core = String(version).trim().split("+", 1)[0].split("-", 1)[0] + if (!core) return null + const parts = core.split(".") + if (parts.length === 0 || parts.some((part) => !/^\d+$/.test(part))) { + return null + } + return parts.map((part) => Number.parseInt(part, 10)) +} + +function compareVersions(a, b) { + const maxLength = Math.max(a.length, b.length) + for (let i = 0; i < maxLength; i++) { + const aPart = a[i] ?? 0 + const bPart = b[i] ?? 0 + if (aPart > bPart) return 1 + if (aPart < bPart) return -1 + } + return 0 +} + +let latestVersion = "" +let latestParts = null + +if (Array.isArray(releases)) { + for (const release of releases) { + if (!release || typeof release.tag_name !== "string" || !release.tag_name.startsWith("cli-v")) { + continue + } + const candidate = release.tag_name.slice("cli-v".length) + const candidateParts = parseVersion(candidate) + if (!candidateParts) continue + if (!latestParts || compareVersions(candidateParts, latestParts) > 0) { + latestVersion = candidate + latestParts = candidateParts + } + } +} + +if (latestVersion) { + process.stdout.write(latestVersion) +} +') + + 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" + + # Create temp directory + TMP_DIR=$(mktemp -d) + trap "rm -rf $TMP_DIR" EXIT + + # Use local tarball if provided, otherwise download + if [ -n "$ROO_LOCAL_TARBALL" ]; then + if [ ! -f "$ROO_LOCAL_TARBALL" ]; then + error "Local tarball not found: $ROO_LOCAL_TARBALL" + fi + info "Using local tarball: $ROO_LOCAL_TARBALL" + cp "$ROO_LOCAL_TARBALL" "$TMP_DIR/$TARBALL" + else + URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}" + + info "Downloading from $URL..." + + # 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 + 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 " cd ~/my-project && roo \"What is this 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 00000000000..3920c6b75f4 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,50 @@ +{ + "name": "@roo-code/cli", + "version": "0.1.17", + "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", + "test:integration": "tsx scripts/integration/run.ts", + "build": "tsup", + "build:extension": "pnpm --filter roo-cline bundle", + "dev": "tsx src/index.ts", + "dev:local": "tsx src/index.ts", + "clean": "rimraf dist .turbo" + }, + "dependencies": { + "@inkjs/ui": "^2.0.0", + "@roo-code/core": "workspace:^", + "@roo-code/types": "workspace:^", + "@roo-code/vscode-shim": "workspace:^", + "@trpc/client": "^11.8.1", + "@vscode/ripgrep": "^1.15.9", + "commander": "^12.1.0", + "cross-spawn": "^7.0.6", + "execa": "^9.5.2", + "fuzzysort": "^3.1.0", + "ink": "^6.6.0", + "p-wait-for": "^5.0.2", + "react": "^19.1.0", + "superjson": "^2.2.6", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@roo-code/config-eslint": "workspace:^", + "@roo-code/config-typescript": "workspace:^", + "@types/node": "^24.1.0", + "@types/react": "^19.1.6", + "ink-testing-library": "^4.0.0", + "rimraf": "^6.0.1", + "tsup": "^8.4.0", + "vitest": "^3.2.3" + } +} diff --git a/apps/cli/scripts/build.sh b/apps/cli/scripts/build.sh new file mode 100755 index 00000000000..fae70473df7 --- /dev/null +++ b/apps/cli/scripts/build.sh @@ -0,0 +1,358 @@ +#!/bin/bash +# Roo Code CLI Local Build Script +# +# Usage: +# ./apps/cli/scripts/build.sh [options] +# +# Options: +# --install Install locally after building +# --skip-verify Skip end-to-end verification tests (faster builds) +# +# Examples: +# ./apps/cli/scripts/build.sh # Build for local testing +# ./apps/cli/scripts/build.sh --install # Build and install locally +# ./apps/cli/scripts/build.sh --skip-verify # Fast local build +# +# This script builds the CLI for your current platform. For official releases +# with multi-platform support, use the GitHub Actions workflow instead: +# .github/workflows/cli-release.yml +# +# Prerequisites: +# - pnpm installed +# - Run from the monorepo root directory + +set -e + +# Parse arguments +LOCAL_INSTALL=false +SKIP_VERIFY=false + +while [[ $# -gt 0 ]]; do + case $1 in + --install) + LOCAL_INSTALL=true + shift + ;; + --skip-verify) + SKIP_VERIFY=true + shift + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + shift + ;; + esac +done + +# 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/6" "Checking prerequisites..." + + 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() { + VERSION=$(node -p "require('$CLI_DIR/package.json').version") + GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + VERSION="${VERSION}-local.${GIT_SHORT_HASH}" + + info "Version: $VERSION" +} + +# Build everything +build() { + step "2/6" "Building extension bundle..." + cd "$REPO_ROOT" + pnpm bundle + + step "3/6" "Building CLI..." + pnpm --filter @roo-code/cli build + + info "Build complete" +} + +# Create release tarball +create_tarball() { + step "4/6" "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 + info "Creating package.json..." + node -e " + const pkg = require('$CLI_DIR/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: '$VERSION', + type: 'module', + dependencies: { + '@inkjs/ui': pkg.dependencies['@inkjs/ui'], + '@trpc/client': pkg.dependencies['@trpc/client'], + 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, + 'ink': pkg.dependencies.ink, + 'p-wait-for': pkg.dependencies['p-wait-for'], + 'react': pkg.dependencies.react, + 'superjson': pkg.dependencies.superjson, + 'zustand': pkg.dependencies.zustand + } + }; + 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 for CommonJS + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary + 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" + 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" + 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'; +import { existsSync } from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Set environment variables for the CLI +process.env.ROO_CLI_ROOT = join(__dirname, '..'); +process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); +const ripgrepPath = join(__dirname, 'rg'); +if (existsSync(ripgrepPath)) { + process.env.ROO_RIPGREP_PATH = ripgrepPath; +} + +// Import and run the actual CLI +await import(join(__dirname, '..', 'lib', 'index.js')); +WRAPPER_EOF + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create empty .env file + touch "$RELEASE_DIR/.env" + + # Strip macOS metadata artifacts before packaging. + find "$RELEASE_DIR" -type f -name "._*" -delete + find "$RELEASE_DIR" -type f -name ".DS_Store" -delete + find "$RELEASE_DIR" -type d -name "__MACOSX" -prune -exec rm -rf {} + + + # Create tarball + info "Creating tarball..." + cd "$REPO_ROOT" + COPYFILE_DISABLE=1 tar \ + --exclude="._*" \ + --exclude=".DS_Store" \ + --exclude="__MACOSX" \ + --exclude="*/._*" \ + --exclude="*/.DS_Store" \ + -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)" +} + +# Verify local installation +verify_local_install() { + if [ "$SKIP_VERIFY" = true ]; then + step "5/6" "Skipping verification (--skip-verify)" + return + fi + + step "5/6" "Verifying installation..." + + VERIFY_DIR="$REPO_ROOT/.verify-release" + VERIFY_INSTALL_DIR="$VERIFY_DIR/cli" + VERIFY_BIN_DIR="$VERIFY_DIR/bin" + + rm -rf "$VERIFY_DIR" + mkdir -p "$VERIFY_DIR" + + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \ + ROO_BIN_DIR="$VERIFY_BIN_DIR" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + rm -rf "$VERIFY_DIR" + error "Installation verification failed!" + } + + # Test --help + if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then + rm -rf "$VERIFY_DIR" + error "CLI --help check failed!" + fi + info "CLI --help check passed" + + # Test --version + if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then + rm -rf "$VERIFY_DIR" + error "CLI --version check failed!" + fi + info "CLI --version check passed" + + cd "$REPO_ROOT" + rm -rf "$VERIFY_DIR" + + info "Verification passed!" +} + +# Install locally +install_local() { + if [ "$LOCAL_INSTALL" = false ]; then + step "6/6" "Skipping install (use --install to auto-install)" + return + fi + + step "6/6" "Installing locally..." + + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + error "Local installation failed!" + } + + info "Local installation complete!" +} + +# Print summary +print_summary() { + echo "" + printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n" + echo "" + echo " Tarball: $REPO_ROOT/$TARBALL" + echo "" + + if [ "$LOCAL_INSTALL" = true ]; then + echo " Installed to: ~/.roo/cli" + echo " Binary: ~/.local/bin/roo" + echo "" + echo " Test it out:" + echo " roo --version" + echo " roo --help" + else + echo " To install manually:" + echo " ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh" + echo "" + echo " Or re-run with --install:" + echo " ./apps/cli/scripts/build.sh --install" + fi + echo "" + echo " For official multi-platform releases, use the GitHub Actions workflow:" + echo " .github/workflows/cli-release.yml" + echo "" +} + +# Main +main() { + echo "" + printf "${BLUE}${BOLD}" + echo " ╭─────────────────────────────────╮" + echo " │ Roo Code CLI Local Build │" + echo " ╰─────────────────────────────────╯" + printf "${NC}" + echo "" + + detect_platform + check_prerequisites + get_version + build + create_tarball + verify_local_install + install_local + print_summary +} + +main diff --git a/apps/cli/scripts/integration/cases/cancel-active-task.ts b/apps/cli/scripts/integration/cases/cancel-active-task.ts new file mode 100644 index 00000000000..db942556b58 --- /dev/null +++ b/apps/cli/scripts/integration/cases/cancel-active-task.ts @@ -0,0 +1,104 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const LONG_PROMPT = + 'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".' + +async function main() { + const startRequestId = `start-a-${Date.now()}` + const cancelRequestId = `cancel-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let startAccepted = false + let startCommandToolUseSeen = false + let sentCancel = false + let cancelDone = false + let sentShutdown = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: LONG_PROMPT, + }) + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "start" && + event.requestId === startRequestId + ) { + startAccepted = true + return + } + + if ( + event.type === "tool_use" && + event.subtype === "command" && + event.done === true && + event.requestId === startRequestId + ) { + startCommandToolUseSeen = true + } + + if (startAccepted && startCommandToolUseSeen && !sentCancel) { + context.sendCommand({ + command: "cancel", + requestId: cancelRequestId, + }) + sentCancel = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "cancel" && + event.requestId === cancelRequestId + ) { + if (event.code === "cancel_requested" || event.code === "no_active_task") { + cancelDone = true + } + return + } + + if (cancelDone && !sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + return + } + + if (event.type === "control" && event.subtype === "error" && event.requestId === cancelRequestId) { + throw new Error( + `cancel command failed with code=${event.code ?? "unknown"} content="${event.content ?? ""}"`, + ) + } + + if (event.type === "error") { + throw new Error(`unexpected stream error event: ${event.content ?? "unknown error"}`) + } + }, + onTimeoutMessage() { + return `timed out waiting for cancel flow (initSeen=${initSeen}, startAccepted=${startAccepted}, startCommandToolUseSeen=${startCommandToolUseSeen}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})` + }, + }) + + if (!startAccepted || !startCommandToolUseSeen || !sentCancel || !cancelDone || !sentShutdown) { + throw new Error( + `cancel flow did not complete expected transitions (startAccepted=${startAccepted}, startCommandToolUseSeen=${startCommandToolUseSeen}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`, + ) + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/cancel-immediately-after-start-ack.ts b/apps/cli/scripts/integration/cases/cancel-immediately-after-start-ack.ts new file mode 100644 index 00000000000..0596062f8f7 --- /dev/null +++ b/apps/cli/scripts/integration/cases/cancel-immediately-after-start-ack.ts @@ -0,0 +1,83 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const LONG_PROMPT = + 'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".' + +async function main() { + const startRequestId = `start-${Date.now()}` + const cancelRequestId = `cancel-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let startAccepted = false + let sentCancel = false + let cancelDone = false + let sentShutdown = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: LONG_PROMPT, + }) + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "start" && + event.requestId === startRequestId && + !startAccepted + ) { + startAccepted = true + context.sendCommand({ + command: "cancel", + requestId: cancelRequestId, + }) + sentCancel = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "cancel" && + event.requestId === cancelRequestId + ) { + if (event.code === "cancel_requested" || event.code === "no_active_task") { + cancelDone = true + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + } + return + } + + if (event.type === "error") { + throw new Error(`unexpected stream error event: ${event.content ?? "unknown error"}`) + } + }, + onTimeoutMessage() { + return `timed out waiting for immediate-cancel flow (initSeen=${initSeen}, startAccepted=${startAccepted}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})` + }, + }) + + if (!startAccepted || !sentCancel || !cancelDone || !sentShutdown) { + throw new Error( + `immediate-cancel flow did not complete expected transitions (startAccepted=${startAccepted}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`, + ) + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/cancel-message-recovery-race.ts b/apps/cli/scripts/integration/cases/cancel-message-recovery-race.ts new file mode 100644 index 00000000000..bb5f6f30c86 --- /dev/null +++ b/apps/cli/scripts/integration/cases/cancel-message-recovery-race.ts @@ -0,0 +1,161 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const START_PROMPT = + 'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".' +const FOLLOWUP_PROMPT = 'After cancellation, reply with only "RACE-OK".' + +async function main() { + const startRequestId = `start-${Date.now()}` + const cancelRequestId = `cancel-${Date.now()}` + const followupRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let sentCancelAndFollowup = false + let sentShutdown = false + let cancelDoneCode: string | undefined + let followupDoneCode: string | undefined + let followupResult = "" + let sawFollowupUserTurn = false + let sawMisroutedToolResult = false + let sawMessageControlError = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: START_PROMPT, + }) + return + } + + if (event.type === "control" && event.subtype === "error") { + if (event.requestId === followupRequestId) { + sawMessageControlError = true + } + throw new Error( + `received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + } + + if ( + !sentCancelAndFollowup && + event.type === "tool_use" && + event.requestId === startRequestId && + event.subtype === "command" + ) { + context.sendCommand({ + command: "cancel", + requestId: cancelRequestId, + }) + context.sendCommand({ + command: "message", + requestId: followupRequestId, + prompt: FOLLOWUP_PROMPT, + }) + sentCancelAndFollowup = true + return + } + + if ( + event.type === "control" && + event.command === "cancel" && + event.subtype === "done" && + event.requestId === cancelRequestId + ) { + cancelDoneCode = event.code + return + } + + if ( + event.type === "control" && + event.command === "message" && + event.subtype === "done" && + event.requestId === followupRequestId + ) { + followupDoneCode = event.code + return + } + + if ( + event.type === "tool_result" && + event.requestId === followupRequestId && + typeof event.content === "string" && + event.content.includes("") + ) { + sawMisroutedToolResult = true + return + } + + if (event.type === "user" && event.requestId === followupRequestId) { + sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("RACE-OK") + return + } + + if (event.type !== "result" || event.done !== true || event.requestId !== followupRequestId) { + return + } + + followupResult = event.content ?? "" + + if (followupResult.trim().length === 0) { + throw new Error("follow-up after cancel produced an empty result") + } + if (cancelDoneCode !== "cancel_requested") { + throw new Error( + `cancel done code mismatch; expected cancel_requested, got "${cancelDoneCode ?? "none"}"`, + ) + } + if (followupDoneCode !== "responded" && followupDoneCode !== "queued") { + throw new Error( + `unexpected follow-up done code after cancel race; expected responded|queued, got "${followupDoneCode ?? "none"}"`, + ) + } + if (sawMessageControlError) { + throw new Error("follow-up message emitted control error in cancel recovery race") + } + if (sawMisroutedToolResult) { + throw new Error( + "follow-up message was misrouted into tool_result () in cancel recovery race", + ) + } + if (!sawFollowupUserTurn) { + throw new Error("follow-up after cancel did not appear as a normal user turn") + } + + console.log(`[PASS] cancel done code: "${cancelDoneCode}"`) + console.log(`[PASS] follow-up done code: "${followupDoneCode}"`) + console.log(`[PASS] follow-up user turn observed: ${sawFollowupUserTurn}`) + console.log(`[PASS] follow-up result: "${followupResult}"`) + + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + }, + onTimeoutMessage() { + return [ + "timed out waiting for cancel-message-recovery-race validation", + `initSeen=${initSeen}`, + `sentCancelAndFollowup=${sentCancelAndFollowup}`, + `cancelDoneCode=${cancelDoneCode ?? "none"}`, + `followupDoneCode=${followupDoneCode ?? "none"}`, + `sawFollowupUserTurn=${sawFollowupUserTurn}`, + `sawMisroutedToolResult=${sawMisroutedToolResult}`, + `sawMessageControlError=${sawMessageControlError}`, + `haveFollowupResult=${Boolean(followupResult)}`, + ].join(" ") + }, + }) +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/cancel-without-active-task.ts b/apps/cli/scripts/integration/cases/cancel-without-active-task.ts new file mode 100644 index 00000000000..5647adaca93 --- /dev/null +++ b/apps/cli/scripts/integration/cases/cancel-without-active-task.ts @@ -0,0 +1,73 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +async function main() { + const cancelRequestId = `cancel-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let cancelAckSeen = false + let cancelDoneSeen = false + let shutdownSent = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "cancel", + requestId: cancelRequestId, + }) + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "cancel" && + event.requestId === cancelRequestId + ) { + cancelAckSeen = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "cancel" && + event.requestId === cancelRequestId + ) { + cancelDoneSeen = true + + if (event.code !== "no_active_task") { + throw new Error(`cancel without task should return no_active_task, got "${event.code ?? "none"}"`) + } + if (event.success !== true) { + throw new Error("cancel without task should be treated as successful no-op") + } + + if (!shutdownSent) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + shutdownSent = true + } + return + } + + if (event.type === "control" && event.subtype === "error") { + throw new Error( + `unexpected control error command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + } + }, + onTimeoutMessage() { + return `timed out waiting for cancel-without-active-task validation (initSeen=${initSeen}, cancelAckSeen=${cancelAckSeen}, cancelDoneSeen=${cancelDoneSeen}, shutdownSent=${shutdownSent})` + }, + }) +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/create-with-session-id-resume-loads-correct-session.ts b/apps/cli/scripts/integration/cases/create-with-session-id-resume-loads-correct-session.ts new file mode 100644 index 00000000000..ded1656e1ee --- /dev/null +++ b/apps/cli/scripts/integration/cases/create-with-session-id-resume-loads-correct-session.ts @@ -0,0 +1,364 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" +import readline from "readline" +import { fileURLToPath } from "url" +import { randomUUID } from "crypto" + +import { execa } from "execa" +import type { TaskSessionEntry } from "@roo-code/core/cli" + +type StreamEvent = { + type?: string + subtype?: string + requestId?: string + command?: string + taskId?: string + content?: string + code?: string + success?: boolean + done?: boolean +} + +const RESUME_TIMEOUT_MS = 180_000 +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function parseStreamEvent(line: string): StreamEvent | null { + const trimmed = line.trim() + + if (!trimmed.startsWith("{")) { + return null + } + + try { + return JSON.parse(trimmed) as StreamEvent + } catch { + return null + } +} + +async function listSessions(cliRoot: string, workspacePath: string): Promise { + const result = await execa("pnpm", ["dev", "list", "sessions", "--workspace", workspacePath, "--format", "json"], { + cwd: cliRoot, + reject: false, + }) + + if (result.exitCode !== 0) { + throw new Error(`list sessions failed with exit code ${result.exitCode}: ${result.stderr || result.stdout}`) + } + + const stdoutLines = result.stdout.split("\n") + const jsonStartIndex = stdoutLines.findIndex((line) => line.trim().startsWith("{")) + if (jsonStartIndex === -1) { + throw new Error(`list sessions output did not contain JSON payload: ${result.stdout}`) + } + + const jsonPayload = stdoutLines.slice(jsonStartIndex).join("\n").trim() + + let parsed: unknown + try { + parsed = JSON.parse(jsonPayload) + } catch (error) { + throw new Error( + `failed to parse list sessions output as JSON: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + if ( + typeof parsed !== "object" || + parsed === null || + !("sessions" in parsed) || + !Array.isArray((parsed as { sessions?: unknown }).sessions) + ) { + throw new Error("list sessions output missing sessions array") + } + + return (parsed as { sessions: TaskSessionEntry[] }).sessions +} + +async function createSessionWithCustomId( + cliRoot: string, + workspacePath: string, + sessionId: string, + prompt: string, +): Promise { + const result = await execa( + "pnpm", + [ + "dev", + "--print", + "--provider", + "openrouter", + "--output-format", + "stream-json", + "--workspace", + workspacePath, + "--create-with-session-id", + sessionId, + prompt, + ], + { + cwd: cliRoot, + reject: false, + }, + ) + + if (result.exitCode !== 0) { + throw new Error( + `create-with-session-id failed for ${sessionId} with exit code ${result.exitCode}: ${result.stderr || result.stdout}`, + ) + } + + const lines = result.stdout.split("\n") + const events = lines.map(parseStreamEvent).filter((event): event is StreamEvent => Boolean(event)) + const errorEvent = events.find((event) => event.type === "error") + + if (errorEvent) { + throw new Error( + `create-with-session-id emitted error for ${sessionId}: code=${errorEvent.code ?? "none"} content=${errorEvent.content ?? ""}`, + ) + } + + const completion = events.find((event) => event.type === "result" && event.done === true) + if (!completion) { + throw new Error(`create-with-session-id did not emit final result for ${sessionId}`) + } + + if (completion.success !== true) { + throw new Error(`create-with-session-id completed unsuccessfully for ${sessionId}`) + } +} + +async function resumeSessionAndSendMarker( + cliRoot: string, + workspacePath: string, + sessionId: string, + messageToken: string, +): Promise { + const pingRequestId = `ping-${Date.now()}` + const messageRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + const messagePrompt = `Resume marker token: ${messageToken}. Reply with exactly "ack-${messageToken}".` + + const child = execa( + "pnpm", + [ + "dev", + "--print", + "--stdin-prompt-stream", + "--provider", + "openrouter", + "--output-format", + "stream-json", + "--workspace", + workspacePath, + "--session-id", + sessionId, + ], + { + cwd: cliRoot, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + reject: false, + forceKillAfterDelay: 2_000, + }, + ) + + child.stderr?.on("data", (chunk) => { + process.stderr.write(chunk) + }) + + let pingSent = false + let messageSent = false + let shutdownSent = false + let sawMessageControlDone = false + let sawUserTurnWithMarker = false + let shutdownTaskId: string | undefined + let handlerError: Error | null = null + let timedOut = false + + const sendCommand = (command: { command: "ping" | "message" | "shutdown"; requestId: string; prompt?: string }) => { + if (!child.stdin || child.stdin.destroyed) { + return + } + child.stdin.write(`${JSON.stringify(command)}\n`) + } + + const timeout = setTimeout(() => { + timedOut = true + handlerError = new Error( + `timed out resuming session ${sessionId} (pingSent=${pingSent}, messageSent=${messageSent}, sawMessageControlDone=${sawMessageControlDone}, sawUserTurnWithMarker=${sawUserTurnWithMarker})`, + ) + child.kill("SIGTERM") + }, RESUME_TIMEOUT_MS) + + const rl = readline.createInterface({ + input: child.stdout!, + crlfDelay: Infinity, + }) + + rl.on("line", (line) => { + process.stdout.write(`${line}\n`) + + const event = parseStreamEvent(line) + if (!event) { + return + } + + if (event.type === "system" && event.subtype === "init" && !pingSent) { + pingSent = true + sendCommand({ command: "ping", requestId: pingRequestId }) + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "ping" && + event.requestId === pingRequestId && + !messageSent + ) { + messageSent = true + sendCommand({ + command: "message", + requestId: messageRequestId, + prompt: messagePrompt, + }) + return + } + + if ( + event.type === "control" && + event.subtype === "error" && + event.command === "message" && + event.requestId === messageRequestId + ) { + handlerError = new Error( + `message command failed while resuming ${sessionId}: code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + child.kill("SIGTERM") + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "message" && + event.requestId === messageRequestId + ) { + sawMessageControlDone = true + return + } + + if (event.type === "user" && event.requestId === messageRequestId && event.content?.includes(messageToken)) { + sawUserTurnWithMarker = true + + if (!shutdownSent) { + shutdownSent = true + sendCommand({ command: "shutdown", requestId: shutdownRequestId }) + } + return + } + + if ( + event.type === "control" && + (event.subtype === "ack" || event.subtype === "done") && + event.command === "shutdown" && + event.requestId === shutdownRequestId && + typeof event.taskId === "string" + ) { + shutdownTaskId = event.taskId + return + } + + if (event.type === "control" && event.subtype === "error" && event.requestId !== shutdownRequestId) { + handlerError = new Error( + `unexpected control error while resuming ${sessionId}: command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + child.kill("SIGTERM") + return + } + }) + + const result = await child + clearTimeout(timeout) + rl.close() + + if (handlerError) { + throw handlerError + } + + if (timedOut) { + throw new Error(`stream resume for ${sessionId} timed out`) + } + + if (result.exitCode !== 0) { + throw new Error(`stream resume for ${sessionId} exited non-zero: ${result.exitCode}`) + } + + if (!sawMessageControlDone) { + throw new Error(`did not observe message control completion while resuming ${sessionId}`) + } + + if (!sawUserTurnWithMarker) { + throw new Error(`did not observe resumed user marker turn while resuming ${sessionId}`) + } + + if (shutdownTaskId !== sessionId) { + throw new Error( + `shutdown taskId did not match resumed session (expected=${sessionId}, actual=${shutdownTaskId ?? "none"})`, + ) + } +} + +async function main() { + const cliRoot = process.env.ROO_CLI_ROOT + ? path.resolve(process.env.ROO_CLI_ROOT) + : path.resolve(__dirname, "../../..") + const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-cli-create-session-id-")) + + const firstSessionId = randomUUID() + const secondSessionId = randomUUID() + const firstMarker = `FIRST-MARKER-${Date.now()}` + const secondMarker = `SECOND-MARKER-${Date.now()}` + + try { + await createSessionWithCustomId( + cliRoot, + workspacePath, + firstSessionId, + `Create first session marker ${firstMarker}. Reply with exactly "ok-${firstMarker}".`, + ) + await createSessionWithCustomId( + cliRoot, + workspacePath, + secondSessionId, + `Create second session marker ${secondMarker}. Reply with exactly "ok-${secondMarker}".`, + ) + + const initialSessions = await listSessions(cliRoot, workspacePath) + if (!initialSessions.some((session) => session.id === firstSessionId)) { + throw new Error(`session list missing first custom session id ${firstSessionId}`) + } + if (!initialSessions.some((session) => session.id === secondSessionId)) { + throw new Error(`session list missing second custom session id ${secondSessionId}`) + } + + const resumeMarkerForFirst = `resume-first-${Date.now()}` + await resumeSessionAndSendMarker(cliRoot, workspacePath, firstSessionId, resumeMarkerForFirst) + + const resumeMarkerForSecond = `resume-second-${Date.now()}` + await resumeSessionAndSendMarker(cliRoot, workspacePath, secondSessionId, resumeMarkerForSecond) + + console.log(`[PASS] created and resumed custom sessions: ${firstSessionId}, ${secondSessionId}`) + } finally { + await fs.rm(workspacePath, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/followup-after-completion.ts b/apps/cli/scripts/integration/cases/followup-after-completion.ts new file mode 100644 index 00000000000..af8e0696bdb --- /dev/null +++ b/apps/cli/scripts/integration/cases/followup-after-completion.ts @@ -0,0 +1,135 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const FIRST_PROMPT = `What is 1+1? Reply with only "2".` +const FOLLOWUP_PROMPT = `Different question now: what is 3+3? Reply with only "6".` + +function parseEventContent(text: string | undefined): string { + return typeof text === "string" ? text : "" +} + +function validateFollowupResult(text: string): void { + if (text.trim().length === 0) { + throw new Error("follow-up produced an empty result") + } +} + +async function main() { + const startRequestId = `start-${Date.now()}` + const followupRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let sentFollowup = false + let sentShutdown = false + let firstResult = "" + let followupResult = "" + let followupDoneCode: string | undefined + let sawFollowupUserTurn = false + let sawMisroutedToolResult = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: FIRST_PROMPT, + }) + return + } + + if (event.type === "control" && event.subtype === "error") { + throw new Error( + `received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + } + + if (event.type !== "result" || event.done !== true) { + if ( + event.type === "control" && + event.requestId === followupRequestId && + event.command === "message" && + event.subtype === "done" + ) { + followupDoneCode = event.code + return + } + + if ( + event.type === "tool_result" && + event.requestId === followupRequestId && + typeof event.content === "string" && + event.content.includes("") + ) { + sawMisroutedToolResult = true + return + } + + if (event.type === "user" && event.requestId === followupRequestId) { + sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3") + return + } + + return + } + + if (event.requestId === startRequestId) { + firstResult = parseEventContent(event.content) + if (!/\b2\b/.test(firstResult)) { + throw new Error(`first result did not answer first prompt; result="${firstResult}"`) + } + + if (!sentFollowup) { + context.sendCommand({ + command: "message", + requestId: followupRequestId, + prompt: FOLLOWUP_PROMPT, + }) + sentFollowup = true + } + return + } + + if (event.requestId !== followupRequestId) { + return + } + + followupResult = parseEventContent(event.content) + validateFollowupResult(followupResult) + + if (followupDoneCode !== "responded") { + throw new Error( + `follow-up message was not routed as ask response; code="${followupDoneCode ?? "none"}"`, + ) + } + + if (!sawFollowupUserTurn) { + throw new Error("follow-up did not appear as a normal user turn in stream output") + } + + if (sawMisroutedToolResult) { + throw new Error("follow-up message was misrouted into tool_result (), old bug reproduced") + } + + console.log(`[PASS] first result="${firstResult}"`) + console.log(`[PASS] follow-up result="${followupResult}"`) + + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + }, + onTimeoutMessage() { + return `timed out waiting for completion (initSeen=${initSeen}, sentFollowup=${sentFollowup}, firstResult=${Boolean(firstResult)}, followupResult=${Boolean(followupResult)})` + }, + }) +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/followup-completion-ask-response-images.ts b/apps/cli/scripts/integration/cases/followup-completion-ask-response-images.ts new file mode 100644 index 00000000000..55b1ccf94c4 --- /dev/null +++ b/apps/cli/scripts/integration/cases/followup-completion-ask-response-images.ts @@ -0,0 +1,136 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const START_PROMPT = 'Answer this question and finish: What is 1+1? Reply with only "2", then complete the task.' +const FOLLOWUP_PROMPT = 'Different question now: what is 3+3? Reply with only "6".' +const ONE_PIXEL_IMAGE = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9R4WQAAAAASUVORK5CYII=" + +async function main() { + const startRequestId = `start-${Date.now()}` + const followupRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let sentFollowup = false + let sentShutdown = false + let followupDoneCode: string | undefined + let sawFollowupUserTurn = false + let sawMisroutedToolResult = false + let sawQueueImageMetadata = false + let shutdownDoneSeen = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: START_PROMPT, + }) + return + } + + if (event.type === "control" && event.subtype === "error") { + throw new Error( + `received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + } + + if ( + event.type === "control" && + event.command === "message" && + event.subtype === "done" && + event.requestId === followupRequestId + ) { + followupDoneCode = event.code + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + return + } + + if ( + event.type === "control" && + event.command === "shutdown" && + event.subtype === "done" && + event.requestId === shutdownRequestId + ) { + shutdownDoneSeen = true + + if (followupDoneCode !== "responded") { + throw new Error( + `follow-up image message was not routed as ask response; code="${followupDoneCode ?? "none"}"`, + ) + } + if (sawQueueImageMetadata) { + throw new Error("follow-up image message was unexpectedly queued (observed queue image metadata)") + } + if (sawMisroutedToolResult) { + throw new Error("follow-up image message was misrouted into tool_result ()") + } + + console.log(`[PASS] follow-up image control code: "${followupDoneCode}"`) + console.log(`[PASS] follow-up image user turn observed before shutdown: ${sawFollowupUserTurn}`) + return + } + + if ( + event.type === "queue" && + Array.isArray(event.queue) && + event.queue.some((item) => item?.imageCount === 1) + ) { + sawQueueImageMetadata = true + return + } + + if ( + event.type === "tool_result" && + event.requestId === followupRequestId && + typeof event.content === "string" && + event.content.includes("") + ) { + sawMisroutedToolResult = true + return + } + + if (event.type === "user" && event.requestId === followupRequestId) { + sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3") + return + } + + if (event.type === "result" && event.done === true && event.requestId === startRequestId && !sentFollowup) { + context.sendCommand({ + command: "message", + requestId: followupRequestId, + prompt: FOLLOWUP_PROMPT, + images: [ONE_PIXEL_IMAGE], + }) + sentFollowup = true + return + } + }, + onTimeoutMessage() { + return [ + "timed out waiting for followup-completion-ask-response-images validation", + `initSeen=${initSeen}`, + `sentFollowup=${sentFollowup}`, + `sentShutdown=${sentShutdown}`, + `shutdownDoneSeen=${shutdownDoneSeen}`, + `followupDoneCode=${followupDoneCode ?? "none"}`, + `sawFollowupUserTurn=${sawFollowupUserTurn}`, + `sawMisroutedToolResult=${sawMisroutedToolResult}`, + `sawQueueImageMetadata=${sawQueueImageMetadata}`, + ].join(" ") + }, + }) +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/followup-completion-ask-response.ts b/apps/cli/scripts/integration/cases/followup-completion-ask-response.ts new file mode 100644 index 00000000000..8b2410f0d02 --- /dev/null +++ b/apps/cli/scripts/integration/cases/followup-completion-ask-response.ts @@ -0,0 +1,153 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const START_PROMPT = 'Answer this question and finish: What is 1+1? Reply with only "2", then complete the task.' +const FOLLOWUP_PROMPT = 'Different question now: what is 3+3? Reply with only "6".' + +async function main() { + const startRequestId = `start-${Date.now()}` + const followupRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let sentFollowup = false + let sentShutdown = false + let startAckCount = 0 + let sawStartControlAfterFollowup = false + let followupDoneCode: string | undefined + let sawFollowupUserTurn = false + let sawMisroutedToolResult = false + let sawQueueEventForFollowupRequest = false + let followupResult = "" + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: START_PROMPT, + }) + return + } + + if (event.type === "control" && event.subtype === "error") { + throw new Error( + `received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + } + + if (event.type === "control" && event.command === "start" && event.subtype === "ack") { + startAckCount += 1 + if (sentFollowup) { + sawStartControlAfterFollowup = true + } + return + } + + if ( + event.type === "control" && + event.command === "message" && + event.subtype === "done" && + event.requestId === followupRequestId + ) { + followupDoneCode = event.code + return + } + + if (event.type === "queue" && event.requestId === followupRequestId) { + sawQueueEventForFollowupRequest = true + return + } + + if ( + event.type === "tool_result" && + event.requestId === followupRequestId && + typeof event.content === "string" && + event.content.includes("") + ) { + sawMisroutedToolResult = true + return + } + + if (event.type === "user" && event.requestId === followupRequestId) { + sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3") + return + } + + if (event.type === "result" && event.done === true && event.requestId === startRequestId && !sentFollowup) { + context.sendCommand({ + command: "message", + requestId: followupRequestId, + prompt: FOLLOWUP_PROMPT, + }) + sentFollowup = true + return + } + + if (event.type !== "result" || event.done !== true || event.requestId !== followupRequestId) { + return + } + + followupResult = event.content ?? "" + if (followupResult.trim().length === 0) { + throw new Error("follow-up produced an empty result") + } + + if (followupDoneCode !== "responded") { + throw new Error( + `follow-up message was not routed as ask response; code="${followupDoneCode ?? "none"}"`, + ) + } + + if (sawMisroutedToolResult) { + throw new Error("follow-up message was misrouted into tool_result (), old bug reproduced") + } + if (sawQueueEventForFollowupRequest) { + throw new Error("follow-up message produced queue events despite responded routing") + } + + if (!sawFollowupUserTurn) { + throw new Error("follow-up did not appear as a normal user turn in stream output") + } + + if (sawStartControlAfterFollowup) { + throw new Error("unexpected start control event after follow-up; message should not trigger a new task") + } + + if (startAckCount !== 1) { + throw new Error(`expected exactly one start ack event, saw ${startAckCount}`) + } + + console.log(`[PASS] follow-up control code: "${followupDoneCode}"`) + console.log(`[PASS] follow-up user turn observed: ${sawFollowupUserTurn}`) + console.log(`[PASS] follow-up result: "${followupResult}"`) + + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + }, + onTimeoutMessage() { + return [ + "timed out waiting for completion ask-response follow-up validation", + `initSeen=${initSeen}`, + `sentFollowup=${sentFollowup}`, + `startAckCount=${startAckCount}`, + `followupDoneCode=${followupDoneCode ?? "none"}`, + `sawFollowupUserTurn=${sawFollowupUserTurn}`, + `sawMisroutedToolResult=${sawMisroutedToolResult}`, + `sawQueueEventForFollowupRequest=${sawQueueEventForFollowupRequest}`, + `haveFollowupResult=${Boolean(followupResult)}`, + ].join(" ") + }, + }) +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/followup-during-streaming.ts b/apps/cli/scripts/integration/cases/followup-during-streaming.ts new file mode 100644 index 00000000000..6f40c8d943c --- /dev/null +++ b/apps/cli/scripts/integration/cases/followup-during-streaming.ts @@ -0,0 +1,159 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const START_PROMPT = 'Answer this question and finish: What is 1+1? Reply with only "2", then complete the task.' +const FOLLOWUP_PROMPT = 'Different question now: what is 3+3? Reply with only "6".' + +function looksLikeAttemptCompletionToolUse(event: StreamEvent): boolean { + if (event.type !== "tool_use") { + return false + } + + if (event.tool_use?.name === "attempt_completion") { + return true + } + + const content = event.content ?? "" + return content.includes('"tool":"attempt_completion"') || content.includes('"name":"attempt_completion"') +} + +function validateFollowupResult(text: string): void { + if (text.trim().length === 0) { + throw new Error("follow-up produced an empty result") + } +} + +async function main() { + const startRequestId = `start-${Date.now()}` + const followupRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let sentFollowup = false + let sentShutdown = false + let sawAttemptCompletion = false + let sawFollowupUserTurn = false + let sawMisroutedToolResult = false + let followupResult = "" + let sawFirstAssistantChunkForStart = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: START_PROMPT, + }) + return + } + + if (event.type === "control" && event.subtype === "error") { + throw new Error( + `received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + } + + if (!sawAttemptCompletion && looksLikeAttemptCompletionToolUse(event)) { + sawAttemptCompletion = true + if (!sentFollowup) { + context.sendCommand({ + command: "message", + requestId: followupRequestId, + prompt: FOLLOWUP_PROMPT, + }) + sentFollowup = true + } + return + } + + if ( + event.type === "assistant" && + event.requestId === startRequestId && + event.done !== true && + !sawFirstAssistantChunkForStart + ) { + sawFirstAssistantChunkForStart = true + if (!sentFollowup) { + context.sendCommand({ + command: "message", + requestId: followupRequestId, + prompt: FOLLOWUP_PROMPT, + }) + sentFollowup = true + } + return + } + + if ( + event.type === "tool_result" && + event.requestId === followupRequestId && + typeof event.content === "string" && + event.content.includes("") + ) { + sawMisroutedToolResult = true + return + } + + if (event.type === "user" && event.requestId === followupRequestId) { + sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3") + return + } + + if (event.type === "result" && event.done === true && event.requestId === startRequestId && !sentFollowup) { + context.sendCommand({ + command: "message", + requestId: followupRequestId, + prompt: FOLLOWUP_PROMPT, + }) + sentFollowup = true + return + } + + if (event.type !== "result" || event.done !== true || event.requestId !== followupRequestId) { + return + } + + followupResult = event.content ?? "" + validateFollowupResult(followupResult) + + if (sawMisroutedToolResult) { + throw new Error("follow-up message was misrouted into tool_result (), old bug reproduced") + } + + if (!sawFollowupUserTurn) { + throw new Error("follow-up did not appear as a normal user turn in stream output") + } + + console.log(`[PASS] saw attempt_completion tool use: ${sawAttemptCompletion}`) + console.log(`[PASS] saw start assistant chunk before follow-up: ${sawFirstAssistantChunkForStart}`) + console.log(`[PASS] follow-up user turn observed: ${sawFollowupUserTurn}`) + console.log(`[PASS] follow-up result: "${followupResult}"`) + + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + }, + onTimeoutMessage() { + return [ + "timed out waiting for follow-up validation", + `initSeen=${initSeen}`, + `sentFollowup=${sentFollowup}`, + `sawAttemptCompletion=${sawAttemptCompletion}`, + `sawFirstAssistantChunkForStart=${sawFirstAssistantChunkForStart}`, + `sawFollowupUserTurn=${sawFollowupUserTurn}`, + `sawMisroutedToolResult=${sawMisroutedToolResult}`, + `haveFollowupResult=${Boolean(followupResult)}`, + ].join(" ") + }, + }) +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/message-images-queue-metadata.ts b/apps/cli/scripts/integration/cases/message-images-queue-metadata.ts new file mode 100644 index 00000000000..f5fee2626f3 --- /dev/null +++ b/apps/cli/scripts/integration/cases/message-images-queue-metadata.ts @@ -0,0 +1,124 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const LONG_PROMPT = + 'Run exactly this command and do not summarize until it finishes: sleep 20 && echo "done". After it finishes, reply with exactly "done".' + +async function main() { + const startRequestId = `start-${Date.now()}` + const messageRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + const testImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" + + let initSeen = false + let startAccepted = false + let messageAccepted = false + let messageQueued = false + let queueImageCountObserved = false + let shutdownSent = false + let shutdownAck = false + let shutdownDone = false + + await runStreamCase({ + timeoutMs: 180_000, + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ command: "start", requestId: startRequestId, prompt: LONG_PROMPT }) + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "start" && + event.requestId === startRequestId && + !startAccepted + ) { + startAccepted = true + + context.sendCommand({ + command: "message", + requestId: messageRequestId, + prompt: "Respond with exactly IMAGE-QUEUED when this message is processed.", + images: [testImage], + }) + + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "message" && + event.requestId === messageRequestId + ) { + messageAccepted = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "message" && + event.requestId === messageRequestId && + event.code === "queued" + ) { + messageQueued = true + return + } + + if ( + event.type === "queue" && + (event.subtype === "snapshot" || event.subtype === "enqueued" || event.subtype === "updated") && + Array.isArray(event.queue) && + event.queue.some((item) => item?.imageCount === 1) + ) { + queueImageCountObserved = true + + if (!shutdownSent) { + context.sendCommand({ command: "shutdown", requestId: shutdownRequestId }) + shutdownSent = true + } + + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "shutdown" && + event.requestId === shutdownRequestId + ) { + shutdownAck = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "shutdown" && + event.requestId === shutdownRequestId + ) { + shutdownDone = true + } + }, + onTimeoutMessage() { + return `timed out waiting for queue image metadata (initSeen=${initSeen}, startAccepted=${startAccepted}, messageAccepted=${messageAccepted}, messageQueued=${messageQueued}, queueImageCountObserved=${queueImageCountObserved}, shutdownSent=${shutdownSent}, shutdownAck=${shutdownAck}, shutdownDone=${shutdownDone})` + }, + }) + + if (!messageAccepted || !messageQueued || !queueImageCountObserved) { + throw new Error( + `expected queued message with image metadata (messageAccepted=${messageAccepted}, messageQueued=${messageQueued}, queueImageCountObserved=${queueImageCountObserved})`, + ) + } + + if (!shutdownAck || !shutdownDone) { + throw new Error("shutdown control events were not fully observed") + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/message-without-active-task.ts b/apps/cli/scripts/integration/cases/message-without-active-task.ts new file mode 100644 index 00000000000..5eb5a2f361d --- /dev/null +++ b/apps/cli/scripts/integration/cases/message-without-active-task.ts @@ -0,0 +1,51 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +async function main() { + const messageRequestId = `message-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + let initSeen = false + let sawNoActiveTaskError = false + let sentShutdown = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "message", + requestId: messageRequestId, + prompt: "Hello", + }) + return + } + + if ( + event.type === "control" && + event.subtype === "error" && + event.requestId === messageRequestId && + event.code === "no_active_task" + ) { + sawNoActiveTaskError = true + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + } + }, + onTimeoutMessage() { + return `timed out waiting for no_active_task error (initSeen=${initSeen}, sawNoActiveTaskError=${sawNoActiveTaskError})` + }, + }) + + if (!sawNoActiveTaskError) { + throw new Error("expected no_active_task error was not observed") + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/mixed-command-ordering.ts b/apps/cli/scripts/integration/cases/mixed-command-ordering.ts new file mode 100644 index 00000000000..3166e780315 --- /dev/null +++ b/apps/cli/scripts/integration/cases/mixed-command-ordering.ts @@ -0,0 +1,148 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const START_PROMPT = + 'Run exactly this command and do not summarize until it finishes: sleep 8 && echo "done". After it finishes, reply with exactly "done".' + +async function main() { + const startRequestId = `start-${Date.now()}` + const pingARequestId = `ping-a-${Date.now()}` + const messageRequestId = `message-${Date.now()}` + const pingBRequestId = `ping-b-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let sentInterleavedCommands = false + let sentShutdown = false + + const eventOrderByRequestId = new Map() + let messageDoneCode: string | undefined + let messageQueueEnqueuedSeen = false + let messageResultSeen = false + + function recordControlEvent(event: StreamEvent): void { + if (!event.requestId || event.type !== "control" || !event.subtype) { + return + } + const existing = eventOrderByRequestId.get(event.requestId) ?? [] + existing.push(event.subtype) + eventOrderByRequestId.set(event.requestId, existing) + } + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: START_PROMPT, + }) + return + } + + recordControlEvent(event) + + if (event.type === "control" && event.subtype === "error") { + throw new Error( + `received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`, + ) + } + + if ( + !sentInterleavedCommands && + event.type === "control" && + event.subtype === "ack" && + event.command === "start" && + event.requestId === startRequestId + ) { + context.sendCommand({ + command: "ping", + requestId: pingARequestId, + }) + context.sendCommand({ + command: "message", + requestId: messageRequestId, + prompt: 'When this queued message is processed, reply with only "INTERLEAVED".', + }) + context.sendCommand({ + command: "ping", + requestId: pingBRequestId, + }) + sentInterleavedCommands = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "message" && + event.requestId === messageRequestId + ) { + messageDoneCode = event.code + return + } + + if ( + event.type === "queue" && + event.subtype === "enqueued" && + event.requestId === startRequestId && + event.queueDepth === 1 + ) { + messageQueueEnqueuedSeen = true + return + } + + if (event.type === "result" && event.done === true && event.requestId === messageRequestId) { + messageResultSeen = true + + const pingAOrder = eventOrderByRequestId.get(pingARequestId) ?? [] + const pingBOrder = eventOrderByRequestId.get(pingBRequestId) ?? [] + const messageOrder = eventOrderByRequestId.get(messageRequestId) ?? [] + + if (pingAOrder.join(",") !== "ack,done") { + throw new Error(`ping A control order mismatch: ${pingAOrder.join(",") || "none"}`) + } + if (pingBOrder.join(",") !== "ack,done") { + throw new Error(`ping B control order mismatch: ${pingBOrder.join(",") || "none"}`) + } + if (messageOrder.join(",") !== "ack,done") { + throw new Error(`message control order mismatch: ${messageOrder.join(",") || "none"}`) + } + if (messageDoneCode !== "queued") { + throw new Error( + `expected interleaved message done code \"queued\", got \"${messageDoneCode ?? "none"}\"`, + ) + } + if (!messageQueueEnqueuedSeen) { + throw new Error("expected queue enqueued event after interleaved message") + } + + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + } + }, + onTimeoutMessage() { + return [ + "timed out waiting for mixed-command-ordering validation", + `initSeen=${initSeen}`, + `sentInterleavedCommands=${sentInterleavedCommands}`, + `messageDoneCode=${messageDoneCode ?? "none"}`, + `messageQueueEnqueuedSeen=${messageQueueEnqueuedSeen}`, + `messageResultSeen=${messageResultSeen}`, + `pingAOrder=${(eventOrderByRequestId.get(pingARequestId) ?? []).join(",") || "none"}`, + `messageOrder=${(eventOrderByRequestId.get(messageRequestId) ?? []).join(",") || "none"}`, + `pingBOrder=${(eventOrderByRequestId.get(pingBRequestId) ?? []).join(",") || "none"}`, + ].join(" ") + }, + }) +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/multi-message-queue-order.ts b/apps/cli/scripts/integration/cases/multi-message-queue-order.ts new file mode 100644 index 00000000000..a45d1ed959f --- /dev/null +++ b/apps/cli/scripts/integration/cases/multi-message-queue-order.ts @@ -0,0 +1,184 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const LONG_PROMPT = + 'Run exactly this command and do not summarize until it finishes: sleep 6 && echo "done". After it finishes, reply with exactly "done".' +const MESSAGE_ONE_PROMPT = 'For this follow-up, reply with only "ALPHA".' +const MESSAGE_TWO_PROMPT = 'For this follow-up, reply with only "BETA".' + +async function main() { + const startRequestId = `start-${Date.now()}` + const firstMessageRequestId = `message-a-${Date.now()}` + const secondMessageRequestId = `message-b-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let startAccepted = false + let sentQueuedMessages = false + let sentShutdown = false + + let firstMessageAccepted = false + let secondMessageAccepted = false + let firstMessageQueued = false + let secondMessageQueued = false + + const resultOrder: string[] = [] + let queueDequeuedByFirst = false + let queueDrainedBySecond = false + let firstResultSeen = false + let secondResultSeen = false + + await runStreamCase({ + timeoutMs: 180_000, + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: LONG_PROMPT, + }) + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "start" && + event.requestId === startRequestId && + !startAccepted + ) { + startAccepted = true + context.sendCommand({ + command: "message", + requestId: firstMessageRequestId, + prompt: MESSAGE_ONE_PROMPT, + }) + context.sendCommand({ + command: "message", + requestId: secondMessageRequestId, + prompt: MESSAGE_TWO_PROMPT, + }) + sentQueuedMessages = true + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "message" && + event.requestId === firstMessageRequestId + ) { + firstMessageAccepted = true + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "message" && + event.requestId === secondMessageRequestId + ) { + secondMessageAccepted = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "message" && + event.requestId === firstMessageRequestId && + event.code === "queued" + ) { + firstMessageQueued = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "message" && + event.requestId === secondMessageRequestId && + event.code === "queued" + ) { + secondMessageQueued = true + return + } + + if ( + event.type === "queue" && + event.subtype === "dequeued" && + event.requestId === firstMessageRequestId && + event.queueDepth === 1 + ) { + queueDequeuedByFirst = true + return + } + + if ( + event.type === "queue" && + event.subtype === "drained" && + event.requestId === secondMessageRequestId && + event.queueDepth === 0 + ) { + queueDrainedBySecond = true + return + } + + if (event.type === "result" && event.done === true) { + if (event.requestId === firstMessageRequestId) { + firstResultSeen = true + resultOrder.push(firstMessageRequestId) + } + if (event.requestId === secondMessageRequestId) { + secondResultSeen = true + resultOrder.push(secondMessageRequestId) + } + } + + if (!firstResultSeen || !secondResultSeen || sentShutdown) { + return + } + + const expectedOrder = [firstMessageRequestId, secondMessageRequestId].join(",") + if (resultOrder.join(",") !== expectedOrder) { + throw new Error( + `queued message result order mismatch; expected=${expectedOrder} actual=${resultOrder.join(",")}`, + ) + } + + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + }, + onTimeoutMessage() { + return `timed out waiting for queued message order validation (initSeen=${initSeen}, startAccepted=${startAccepted}, sentQueuedMessages=${sentQueuedMessages}, firstMessageAccepted=${firstMessageAccepted}, secondMessageAccepted=${secondMessageAccepted}, firstMessageQueued=${firstMessageQueued}, secondMessageQueued=${secondMessageQueued}, queueDequeuedByFirst=${queueDequeuedByFirst}, queueDrainedBySecond=${queueDrainedBySecond}, resultOrder=${resultOrder.join(" -> ")}, firstResultSeen=${firstResultSeen}, secondResultSeen=${secondResultSeen})` + }, + }) + + if ( + !firstMessageAccepted || + !secondMessageAccepted || + !firstMessageQueued || + !secondMessageQueued || + !queueDequeuedByFirst || + !queueDrainedBySecond + ) { + throw new Error( + `expected both queued messages to be accepted/queued and queue transitions observed (firstMessageAccepted=${firstMessageAccepted}, secondMessageAccepted=${secondMessageAccepted}, firstMessageQueued=${firstMessageQueued}, secondMessageQueued=${secondMessageQueued}, queueDequeuedByFirst=${queueDequeuedByFirst}, queueDrainedBySecond=${queueDrainedBySecond})`, + ) + } + + const expectedOrder = [firstMessageRequestId, secondMessageRequestId].join(",") + if (resultOrder.join(",") !== expectedOrder) { + throw new Error( + `queued message result order mismatch; expected=${expectedOrder} actual=${resultOrder.join(",")}`, + ) + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/shutdown-while-running.ts b/apps/cli/scripts/integration/cases/shutdown-while-running.ts new file mode 100644 index 00000000000..6bc0a369da0 --- /dev/null +++ b/apps/cli/scripts/integration/cases/shutdown-while-running.ts @@ -0,0 +1,76 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const LONG_PROMPT = + 'Run exactly this command and do not summarize until it finishes: sleep 20 && echo "done". After it finishes, reply with exactly "done".' + +async function main() { + const startRequestId = `start-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let startAccepted = false + let shutdownSent = false + let shutdownAck = false + let shutdownDone = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: startRequestId, + prompt: LONG_PROMPT, + }) + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "start" && + event.requestId === startRequestId && + !startAccepted + ) { + startAccepted = true + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + shutdownSent = true + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "shutdown" && + event.requestId === shutdownRequestId + ) { + shutdownAck = true + return + } + + if ( + event.type === "control" && + event.subtype === "done" && + event.command === "shutdown" && + event.requestId === shutdownRequestId + ) { + shutdownDone = true + } + }, + onTimeoutMessage() { + return `timed out waiting for shutdown flow (initSeen=${initSeen}, startAccepted=${startAccepted}, shutdownSent=${shutdownSent}, shutdownAck=${shutdownAck}, shutdownDone=${shutdownDone})` + }, + }) + + if (!shutdownAck || !shutdownDone) { + throw new Error("shutdown control events were not fully observed") + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/cases/start-while-busy.ts b/apps/cli/scripts/integration/cases/start-while-busy.ts new file mode 100644 index 00000000000..b8fa9d30662 --- /dev/null +++ b/apps/cli/scripts/integration/cases/start-while-busy.ts @@ -0,0 +1,77 @@ +import { runStreamCase, StreamEvent } from "../lib/stream-harness" + +const LONG_PROMPT = + 'Run exactly this command and do not summarize until it finishes: sleep 8 && echo "done". After it finishes, reply with exactly "done".' + +async function main() { + const firstStartRequestId = `start-a-${Date.now()}` + const secondStartRequestId = `start-b-${Date.now()}` + const shutdownRequestId = `shutdown-${Date.now()}` + + let initSeen = false + let firstStartAccepted = false + let secondStartSent = false + let sawTaskBusyError = false + let sentShutdown = false + + await runStreamCase({ + onEvent(event: StreamEvent, context) { + if (event.type === "system" && event.subtype === "init" && !initSeen) { + initSeen = true + context.sendCommand({ + command: "start", + requestId: firstStartRequestId, + prompt: LONG_PROMPT, + }) + return + } + + if ( + event.type === "control" && + event.subtype === "ack" && + event.command === "start" && + event.requestId === firstStartRequestId && + !firstStartAccepted + ) { + firstStartAccepted = true + context.sendCommand({ + command: "start", + requestId: secondStartRequestId, + prompt: "What is 1+1? Reply with only 2.", + }) + secondStartSent = true + return + } + + if ( + event.type === "control" && + event.subtype === "error" && + event.command === "start" && + event.requestId === secondStartRequestId && + event.code === "task_busy" + ) { + sawTaskBusyError = true + if (!sentShutdown) { + context.sendCommand({ + command: "shutdown", + requestId: shutdownRequestId, + }) + sentShutdown = true + } + return + } + }, + onTimeoutMessage() { + return `timed out waiting for task_busy error (initSeen=${initSeen}, firstStartAccepted=${firstStartAccepted}, secondStartSent=${secondStartSent}, sawTaskBusyError=${sawTaskBusyError})` + }, + }) + + if (!sawTaskBusyError) { + throw new Error("expected task_busy error for second start command was not observed") + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/scripts/integration/lib/stream-harness.ts b/apps/cli/scripts/integration/lib/stream-harness.ts new file mode 100644 index 00000000000..2693103309d --- /dev/null +++ b/apps/cli/scripts/integration/lib/stream-harness.ts @@ -0,0 +1,152 @@ +import path from "path" +import { fileURLToPath } from "url" +import readline from "readline" + +import { execa } from "execa" + +export type StreamEvent = { + type?: string + subtype?: string + requestId?: string + command?: string + content?: string + code?: string + success?: boolean + done?: boolean + id?: number + queueDepth?: number + queue?: Array<{ id?: string; text?: string; imageCount?: number; timestamp?: number }> + tool_use?: { + name?: string + input?: Record + } + tool_result?: { + name?: string + output?: string + } +} + +export type StreamCommand = { + command: "start" | "message" | "cancel" | "ping" | "shutdown" + requestId: string + prompt?: string + images?: string[] +} + +export interface StreamCaseContext { + readonly cliRoot: string + readonly timeoutMs: number + nextRequestId(prefix: string): string + sendCommand(command: StreamCommand): void +} + +export interface RunStreamCaseOptions { + timeoutMs?: number + onEvent: (event: StreamEvent, context: StreamCaseContext) => void + onTimeoutMessage?: (context: StreamCaseContext) => string +} + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const defaultCliRoot = path.resolve(__dirname, "../../..") + +function parseEvent(line: string): StreamEvent | null { + const trimmed = line.trim() + + if (!trimmed.startsWith("{")) { + return null + } + + try { + return JSON.parse(trimmed) as StreamEvent + } catch { + return null + } +} + +export async function runStreamCase(options: RunStreamCaseOptions): Promise { + const cliRoot = process.env.ROO_CLI_ROOT ? path.resolve(process.env.ROO_CLI_ROOT) : defaultCliRoot + const timeoutMs = options.timeoutMs ?? 120_000 + + const child = execa( + "pnpm", + ["dev", "--print", "--stdin-prompt-stream", "--provider", "openrouter", "--output-format", "stream-json"], + { + cwd: cliRoot, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + reject: false, + forceKillAfterDelay: 2_000, + }, + ) + + child.stderr?.on("data", (chunk) => { + process.stderr.write(chunk) + }) + + let requestCounter = 0 + + const context: StreamCaseContext = { + cliRoot, + timeoutMs, + nextRequestId(prefix: string): string { + requestCounter += 1 + return `${prefix}-${Date.now()}-${requestCounter}` + }, + sendCommand(command: StreamCommand): void { + if (child.stdin?.destroyed) { + return + } + + child.stdin.write(`${JSON.stringify(command)}\n`) + }, + } + + let handlerError: Error | null = null + let timedOut = false + + const timeout = setTimeout(() => { + timedOut = true + const message = options.onTimeoutMessage?.(context) ?? "timed out waiting for stream scenario completion" + handlerError = new Error(message) + child.kill("SIGTERM") + }, timeoutMs) + + const rl = readline.createInterface({ + input: child.stdout!, + crlfDelay: Infinity, + }) + + rl.on("line", (line) => { + process.stdout.write(`${line}\n`) + + const event = parseEvent(line) + + if (!event) { + return + } + + try { + options.onEvent(event, context) + } catch (error) { + handlerError = error instanceof Error ? error : new Error(String(error)) + child.kill("SIGTERM") + } + }) + + const result = await child + clearTimeout(timeout) + rl.close() + + if (handlerError) { + throw handlerError + } + + if (timedOut) { + throw new Error("stream scenario timed out") + } + + if (result.exitCode !== 0) { + throw new Error(`CLI exited with non-zero code: ${result.exitCode}`) + } +} diff --git a/apps/cli/scripts/integration/run.ts b/apps/cli/scripts/integration/run.ts new file mode 100644 index 00000000000..a39c8b14ae2 --- /dev/null +++ b/apps/cli/scripts/integration/run.ts @@ -0,0 +1,111 @@ +import fs from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" + +import { execa } from "execa" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const cliRoot = path.resolve(__dirname, "../..") +const casesDir = path.resolve(__dirname, "cases") + +interface RunnerOptions { + listOnly: boolean + match?: string +} + +function parseArgs(argv: string[]): RunnerOptions { + let listOnly = false + let match: string | undefined + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === "--list") { + listOnly = true + continue + } + if (arg === "--match") { + match = argv[i + 1] + i += 1 + continue + } + } + + return { listOnly, match } +} + +async function discoverCaseFiles(match?: string): Promise { + const entries = await fs.readdir(casesDir, { withFileTypes: true }) + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) + .map((entry) => path.resolve(casesDir, entry.name)) + .sort((a, b) => a.localeCompare(b)) + + if (!match) { + return files + } + + const normalized = match.toLowerCase() + return files.filter((file) => path.basename(file).toLowerCase().includes(normalized)) +} + +async function runCase(caseFile: string): Promise { + const caseName = path.basename(caseFile, ".ts") + console.log(`\n[RUN] ${caseName}`) + + await execa("tsx", [caseFile], { + cwd: cliRoot, + stdio: "inherit", + reject: true, + env: { + ...process.env, + ROO_CLI_ROOT: cliRoot, + }, + }) + + console.log(`[PASS] ${caseName}`) +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + const caseFiles = await discoverCaseFiles(options.match) + + if (caseFiles.length === 0) { + throw new Error( + options.match ? `no integration cases matched --match "${options.match}"` : "no integration cases found", + ) + } + + if (options.listOnly) { + console.log("Available integration cases:") + for (const file of caseFiles) { + console.log(`- ${path.basename(file, ".ts")}`) + } + return + } + + const failures: Array<{ caseName: string; error: string }> = [] + + for (const caseFile of caseFiles) { + const caseName = path.basename(caseFile, ".ts") + try { + await runCase(caseFile) + } catch (error) { + const errorText = error instanceof Error ? error.message : String(error) + failures.push({ caseName, error: errorText }) + console.error(`[FAIL] ${caseName}: ${errorText}`) + } + } + + const total = caseFiles.length + const passed = total - failures.length + console.log(`\nSummary: ${passed}/${total} passed`) + + if (failures.length > 0) { + process.exitCode = 1 + } +} + +main().catch((error) => { + console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/apps/cli/src/__tests__/index.test.ts b/apps/cli/src/__tests__/index.test.ts new file mode 100644 index 00000000000..aa9649373d0 --- /dev/null +++ b/apps/cli/src/__tests__/index.test.ts @@ -0,0 +1,126 @@ +/** + * Integration tests for CLI + * + * These tests require: + * 1. RUN_CLI_INTEGRATION_TESTS=true environment variable (opt-in) + * 2. A valid OPENROUTER_API_KEY environment variable + * 3. A built CLI at apps/cli/dist (will auto-build if missing) + * 4. A built extension at src/dist (will auto-build if missing) + * + * Run with: RUN_CLI_INTEGRATION_TESTS=true OPENROUTER_API_KEY=sk-or-v1-... pnpm test + */ + +// pnpm --filter @roo-code/cli test src/__tests__/index.test.ts + +import path from "path" +import fs from "fs" +import { execSync, spawn, type ChildProcess } from "child_process" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const RUN_INTEGRATION_TESTS = process.env.RUN_CLI_INTEGRATION_TESTS === "true" +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY +const hasApiKey = !!OPENROUTER_API_KEY + +function findCliRoot(): string { + // From apps/cli/src/__tests__, go up to apps/cli. + return path.resolve(__dirname, "../..") +} + +function findMonorepoRoot(): string { + // From apps/cli/src/__tests__, go up to monorepo root. + return path.resolve(__dirname, "../../../..") +} + +function isCliBuilt(): boolean { + return fs.existsSync(path.join(findCliRoot(), "dist", "index.js")) +} + +function isExtensionBuilt(): boolean { + const monorepoRoot = findMonorepoRoot() + const extensionPath = path.join(monorepoRoot, "src/dist") + return fs.existsSync(path.join(extensionPath, "extension.js")) +} + +function buildCliIfNeeded(): void { + if (!isCliBuilt()) { + execSync("pnpm build", { cwd: findCliRoot(), stdio: "inherit" }) + console.log("CLI build complete.") + } +} + +function buildExtensionIfNeeded(): void { + if (!isExtensionBuilt()) { + execSync("pnpm --filter roo-cline bundle", { cwd: findMonorepoRoot(), stdio: "inherit" }) + console.log("Extension build complete.") + } +} + +function runCli( + args: string[], + options: { timeout?: number } = {}, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + const timeout = options.timeout ?? 60000 + + let stdout = "" + let stderr = "" + let timedOut = false + + const proc: ChildProcess = spawn("pnpm", ["start", ...args], { + cwd: findCliRoot(), + env: { ...process.env, OPENROUTER_API_KEY, NO_COLOR: "1", FORCE_COLOR: "0" }, + stdio: ["pipe", "pipe", "pipe"], + }) + + const timeoutId = setTimeout(() => { + timedOut = true + proc.kill("SIGTERM") + }, timeout) + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString() + }) + + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString() + }) + + proc.on("close", (code: number | null) => { + clearTimeout(timeoutId) + resolve({ stdout, stderr, exitCode: timedOut ? -1 : (code ?? 1) }) + }) + + proc.on("error", (error: Error) => { + clearTimeout(timeoutId) + stderr += error.message + resolve({ stdout, stderr, exitCode: 1 }) + }) + }) +} + +describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey)("CLI Integration Tests", () => { + beforeAll(() => { + buildExtensionIfNeeded() + buildCliIfNeeded() + }) + + it("should complete end-to-end task execution via CLI", async () => { + const result = await runCli( + ["--no-tui", "-m", "anthropic/claude-sonnet-4.5", "-M", "ask", "-r", "disabled", "-P", "1+1=?"], + { timeout: 30_000 }, + ) + + console.log("CLI stdout:", result.stdout) + + if (result.stderr) { + console.log("CLI stderr:", result.stderr) + } + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("2") + expect(result.stdout).toContain("[task complete]") + }, 30_000) +}) diff --git a/apps/cli/src/agent/__tests__/events.test.ts b/apps/cli/src/agent/__tests__/events.test.ts new file mode 100644 index 00000000000..6d5802fa3f3 --- /dev/null +++ b/apps/cli/src/agent/__tests__/events.test.ts @@ -0,0 +1,35 @@ +import type { ClineMessage } from "@roo-code/types" + +import { detectAgentState } from "../agent-state.js" +import { taskCompleted } from "../events.js" + +function createMessage(overrides: Partial): ClineMessage { + return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides } +} + +describe("taskCompleted", () => { + it("returns true for completion_result", () => { + const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })]) + const current = detectAgentState([createMessage({ type: "ask", ask: "completion_result", partial: false })]) + + expect(taskCompleted(previous, current)).toBe(true) + }) + + it("returns true for resume_completed_task", () => { + const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })]) + const current = detectAgentState([createMessage({ type: "ask", ask: "resume_completed_task", partial: false })]) + + expect(taskCompleted(previous, current)).toBe(true) + }) + + it("returns false for recoverable idle asks", () => { + const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })]) + const mistakeLimit = detectAgentState([ + createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false }), + ]) + const apiFailed = detectAgentState([createMessage({ type: "ask", ask: "api_req_failed", partial: false })]) + + expect(taskCompleted(previous, mistakeLimit)).toBe(false) + expect(taskCompleted(previous, apiFailed)).toBe(false) + }) +}) diff --git a/apps/cli/src/agent/__tests__/extension-client.test.ts b/apps/cli/src/agent/__tests__/extension-client.test.ts new file mode 100644 index 00000000000..7a63fe0174c --- /dev/null +++ b/apps/cli/src/agent/__tests__/extension-client.test.ts @@ -0,0 +1,850 @@ +import { + type ClineMessage, + type ExtensionMessage, + isIdleAsk, + isResumableAsk, + isInteractiveAsk, + isNonBlockingAsk, +} from "@roo-code/types" + +import { AgentLoopState, detectAgentState } from "../agent-state.js" +import { createMockClient } from "../extension-client.js" + +function createMessage(overrides: Partial): ClineMessage { + return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides } +} + +function createStateMessage(messages: ClineMessage[], mode?: string): ExtensionMessage { + return { type: "state", state: { clineMessages: messages, mode } } as ExtensionMessage +} + +describe("detectAgentState", () => { + describe("NO_TASK state", () => { + it("should return NO_TASK for empty messages array", () => { + const state = detectAgentState([]) + expect(state.state).toBe(AgentLoopState.NO_TASK) + expect(state.isWaitingForInput).toBe(false) + expect(state.isRunning).toBe(false) + }) + + it("should return NO_TASK for undefined messages", () => { + const state = detectAgentState(undefined as unknown as ClineMessage[]) + expect(state.state).toBe(AgentLoopState.NO_TASK) + }) + }) + + describe("STREAMING state", () => { + it("should detect streaming when partial is true", () => { + const messages = [createMessage({ type: "ask", ask: "tool", partial: true })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.STREAMING) + expect(state.isStreaming).toBe(true) + expect(state.isWaitingForInput).toBe(false) + }) + + it("should detect streaming when api_req_started has no cost", () => { + const messages = [ + createMessage({ + say: "api_req_started", + text: JSON.stringify({ tokensIn: 100 }), // No cost field. + }), + ] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.STREAMING) + expect(state.isStreaming).toBe(true) + }) + + it("should NOT be streaming when api_req_started has cost", () => { + const messages = [ + createMessage({ + say: "api_req_started", + text: JSON.stringify({ cost: 0.001, tokensIn: 100 }), + }), + ] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.RUNNING) + expect(state.isStreaming).toBe(false) + }) + }) + + describe("WAITING_FOR_INPUT state", () => { + it("should detect waiting for tool approval", () => { + const messages = [createMessage({ type: "ask", ask: "tool", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(state.isWaitingForInput).toBe(true) + expect(state.currentAsk).toBe("tool") + expect(state.requiredAction).toBe("approve") + }) + + it("should detect waiting for command approval", () => { + const messages = [createMessage({ type: "ask", ask: "command", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(state.currentAsk).toBe("command") + expect(state.requiredAction).toBe("approve") + }) + + it("should detect waiting for followup answer", () => { + const messages = [createMessage({ type: "ask", ask: "followup", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(state.currentAsk).toBe("followup") + expect(state.requiredAction).toBe("answer") + }) + + it("should detect waiting for use_mcp_server approval", () => { + const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(state.requiredAction).toBe("approve") + }) + }) + + describe("IDLE state", () => { + it("should detect completion_result as idle", () => { + const messages = [createMessage({ type: "ask", ask: "completion_result", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.IDLE) + expect(state.isWaitingForInput).toBe(true) + expect(state.requiredAction).toBe("start_task") + }) + + it("should detect api_req_failed as idle", () => { + const messages = [createMessage({ type: "ask", ask: "api_req_failed", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.IDLE) + expect(state.requiredAction).toBe("retry_or_new_task") + }) + + it("should detect mistake_limit_reached as idle", () => { + const messages = [createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.IDLE) + expect(state.requiredAction).toBe("proceed_or_new_task") + }) + + it("should detect auto_approval_max_req_reached as idle", () => { + const messages = [createMessage({ type: "ask", ask: "auto_approval_max_req_reached", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.IDLE) + expect(state.requiredAction).toBe("start_new_task") + }) + + it("should detect resume_completed_task as idle", () => { + const messages = [createMessage({ type: "ask", ask: "resume_completed_task", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.IDLE) + expect(state.requiredAction).toBe("start_new_task") + }) + }) + + describe("RESUMABLE state", () => { + it("should detect resume_task as resumable", () => { + const messages = [createMessage({ type: "ask", ask: "resume_task", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.RESUMABLE) + expect(state.isWaitingForInput).toBe(true) + expect(state.requiredAction).toBe("resume_or_abandon") + }) + }) + + describe("RUNNING state", () => { + it("should detect running for say messages", () => { + const messages = [ + createMessage({ + say: "api_req_started", + text: JSON.stringify({ cost: 0.001 }), + }), + createMessage({ say: "text", text: "Working on it..." }), + ] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.RUNNING) + expect(state.isRunning).toBe(true) + expect(state.isWaitingForInput).toBe(false) + }) + + it("should detect running for command_output (non-blocking)", () => { + const messages = [createMessage({ type: "ask", ask: "command_output", partial: false })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.RUNNING) + expect(state.requiredAction).toBe("continue_or_abort") + }) + }) +}) + +describe("Type Guards", () => { + describe("isIdleAsk", () => { + it("should return true for idle asks", () => { + expect(isIdleAsk("completion_result")).toBe(true) + expect(isIdleAsk("api_req_failed")).toBe(true) + expect(isIdleAsk("mistake_limit_reached")).toBe(true) + expect(isIdleAsk("auto_approval_max_req_reached")).toBe(true) + expect(isIdleAsk("resume_completed_task")).toBe(true) + }) + + it("should return false for non-idle asks", () => { + expect(isIdleAsk("tool")).toBe(false) + expect(isIdleAsk("followup")).toBe(false) + expect(isIdleAsk("resume_task")).toBe(false) + }) + }) + + describe("isInteractiveAsk", () => { + it("should return true for interactive asks", () => { + expect(isInteractiveAsk("tool")).toBe(true) + expect(isInteractiveAsk("command")).toBe(true) + expect(isInteractiveAsk("followup")).toBe(true) + expect(isInteractiveAsk("use_mcp_server")).toBe(true) + }) + + it("should return false for non-interactive asks", () => { + expect(isInteractiveAsk("completion_result")).toBe(false) + expect(isInteractiveAsk("command_output")).toBe(false) + }) + }) + + describe("isResumableAsk", () => { + it("should return true for resumable asks", () => { + expect(isResumableAsk("resume_task")).toBe(true) + }) + + it("should return false for non-resumable asks", () => { + expect(isResumableAsk("completion_result")).toBe(false) + expect(isResumableAsk("tool")).toBe(false) + }) + }) + + describe("isNonBlockingAsk", () => { + it("should return true for non-blocking asks", () => { + expect(isNonBlockingAsk("command_output")).toBe(true) + }) + + it("should return false for blocking asks", () => { + expect(isNonBlockingAsk("tool")).toBe(false) + expect(isNonBlockingAsk("followup")).toBe(false) + }) + }) +}) + +describe("ExtensionClient", () => { + describe("State queries", () => { + it("should return NO_TASK when not initialized", () => { + const { client } = createMockClient() + expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK) + expect(client.isInitialized()).toBe(false) + }) + + it("should update state when receiving messages", () => { + const { client } = createMockClient() + + const message = createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]) + + client.handleMessage(message) + + expect(client.isInitialized()).toBe(true) + expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(client.isWaitingForInput()).toBe(true) + expect(client.getCurrentAsk()).toBe("tool") + }) + }) + + describe("Event emission", () => { + it("should emit stateChange events", () => { + const { client } = createMockClient() + const stateChanges: AgentLoopState[] = [] + + client.onStateChange((event) => { + stateChanges.push(event.currentState.state) + }) + + client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })])) + + expect(stateChanges).toContain(AgentLoopState.WAITING_FOR_INPUT) + }) + + it("should emit waitingForInput events", () => { + const { client } = createMockClient() + const waitingEvents: string[] = [] + + client.onWaitingForInput((event) => { + waitingEvents.push(event.ask) + }) + + client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "followup", partial: false })])) + + expect(waitingEvents).toContain("followup") + }) + + it("should allow unsubscribing from events", () => { + const { client } = createMockClient() + let callCount = 0 + + const unsubscribe = client.onStateChange(() => { + callCount++ + }) + + client.handleMessage(createStateMessage([createMessage({ say: "text" })])) + expect(callCount).toBe(1) + + unsubscribe() + + client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })])) + expect(callCount).toBe(1) // Should not increase. + }) + + it("should emit modeChanged events", () => { + const { client } = createMockClient() + const modeChanges: { previousMode: string | undefined; currentMode: string }[] = [] + + client.onModeChanged((event) => { + modeChanges.push(event) + }) + + // Set initial mode + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + + expect(modeChanges).toHaveLength(1) + expect(modeChanges[0]).toEqual({ previousMode: undefined, currentMode: "code" }) + + // Change mode + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect")) + + expect(modeChanges).toHaveLength(2) + expect(modeChanges[1]).toEqual({ previousMode: "code", currentMode: "architect" }) + }) + + it("should not emit modeChanged when mode stays the same", () => { + const { client } = createMockClient() + let modeChangeCount = 0 + + client.onModeChanged(() => { + modeChangeCount++ + }) + + // Set initial mode + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + expect(modeChangeCount).toBe(1) + + // Same mode - should not emit + client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "code")) + expect(modeChangeCount).toBe(1) + }) + }) + + describe("Response methods", () => { + it("should send approve response", () => { + const { client, sentMessages } = createMockClient() + + client.approve() + + expect(sentMessages).toHaveLength(1) + expect(sentMessages[0]).toEqual({ + type: "askResponse", + askResponse: "yesButtonClicked", + text: undefined, + images: undefined, + }) + }) + + it("should send reject response", () => { + const { client, sentMessages } = createMockClient() + + client.reject() + + expect(sentMessages).toHaveLength(1) + const msg = sentMessages[0] + expect(msg).toBeDefined() + expect(msg?.askResponse).toBe("noButtonClicked") + }) + + it("should send text response", () => { + const { client, sentMessages } = createMockClient() + + client.respond("My answer", ["image-data"]) + + expect(sentMessages).toHaveLength(1) + expect(sentMessages[0]).toEqual({ + type: "askResponse", + askResponse: "messageResponse", + text: "My answer", + images: ["image-data"], + }) + }) + + it("should send newTask message", () => { + const { client, sentMessages } = createMockClient() + + client.newTask("Build a web app") + + expect(sentMessages).toHaveLength(1) + expect(sentMessages[0]).toEqual({ + type: "newTask", + text: "Build a web app", + images: undefined, + }) + }) + + it("should send clearTask message", () => { + const { client, sentMessages } = createMockClient() + + client.clearTask() + + expect(sentMessages).toHaveLength(1) + expect(sentMessages[0]).toEqual({ + type: "clearTask", + }) + }) + + it("should send cancelTask message", () => { + const { client, sentMessages } = createMockClient() + + client.cancelTask() + + expect(sentMessages).toHaveLength(1) + expect(sentMessages[0]).toEqual({ + type: "cancelTask", + }) + }) + + it("should send terminal continue operation", () => { + const { client, sentMessages } = createMockClient() + + client.continueTerminal() + + expect(sentMessages).toHaveLength(1) + expect(sentMessages[0]).toEqual({ + type: "terminalOperation", + terminalOperation: "continue", + }) + }) + + it("should send terminal abort operation", () => { + const { client, sentMessages } = createMockClient() + + client.abortTerminal() + + expect(sentMessages).toHaveLength(1) + expect(sentMessages[0]).toEqual({ + type: "terminalOperation", + terminalOperation: "abort", + }) + }) + }) + + describe("Message handling", () => { + it("should handle JSON string messages", () => { + const { client } = createMockClient() + + const message = JSON.stringify( + createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]), + ) + + client.handleMessage(message) + + expect(client.getCurrentState()).toBe(AgentLoopState.IDLE) + }) + + it("should ignore invalid JSON", () => { + const { client } = createMockClient() + + client.handleMessage("not valid json") + + expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK) + }) + + it("should handle messageUpdated messages", () => { + const { client } = createMockClient() + + // First, set initial state. + client.handleMessage( + createStateMessage([createMessage({ ts: 123, type: "ask", ask: "tool", partial: true })]), + ) + + expect(client.isStreaming()).toBe(true) + + // Now update the message. + client.handleMessage({ + type: "messageUpdated", + clineMessage: createMessage({ ts: 123, type: "ask", ask: "tool", partial: false }), + }) + + expect(client.isStreaming()).toBe(false) + expect(client.isWaitingForInput()).toBe(true) + }) + }) + + describe("Reset functionality", () => { + it("should reset state", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })])) + + expect(client.isInitialized()).toBe(true) + expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT) + + client.reset() + + expect(client.isInitialized()).toBe(false) + expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK) + }) + + it("should reset mode on reset", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + expect(client.getCurrentMode()).toBe("code") + + client.reset() + + expect(client.getCurrentMode()).toBeUndefined() + }) + }) + + describe("Mode tracking", () => { + it("should return undefined mode when not initialized", () => { + const { client } = createMockClient() + expect(client.getCurrentMode()).toBeUndefined() + }) + + it("should track mode from state messages", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + + expect(client.getCurrentMode()).toBe("code") + }) + + it("should update mode when it changes", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + expect(client.getCurrentMode()).toBe("code") + + client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "architect")) + expect(client.getCurrentMode()).toBe("architect") + }) + + it("should preserve mode when state message has no mode", () => { + const { client } = createMockClient() + + // Set initial mode + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code")) + expect(client.getCurrentMode()).toBe("code") + + // State update without mode - should preserve existing mode + client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })])) + expect(client.getCurrentMode()).toBe("code") + }) + + it("should preserve mode when task is cleared", () => { + const { client } = createMockClient() + + client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect")) + expect(client.getCurrentMode()).toBe("architect") + + client.clearTask() + // Mode should be preserved after clear + expect(client.getCurrentMode()).toBe("architect") + }) + }) +}) + +describe("Integration", () => { + it("should handle a complete task flow", () => { + const { client } = createMockClient() + const states: AgentLoopState[] = [] + + client.onStateChange((event) => { + states.push(event.currentState.state) + }) + + // 1. Task starts, API request begins. + client.handleMessage( + createStateMessage([ + createMessage({ + say: "api_req_started", + text: JSON.stringify({}), // No cost = streaming. + }), + ]), + ) + expect(client.isStreaming()).toBe(true) + + // 2. API request completes. + client.handleMessage( + createStateMessage([ + createMessage({ + say: "api_req_started", + text: JSON.stringify({ cost: 0.001 }), + }), + createMessage({ say: "text", text: "I'll help you with that." }), + ]), + ) + expect(client.isStreaming()).toBe(false) + expect(client.isRunning()).toBe(true) + + // 3. Tool ask (partial). + client.handleMessage( + createStateMessage([ + createMessage({ + say: "api_req_started", + text: JSON.stringify({ cost: 0.001 }), + }), + createMessage({ say: "text", text: "I'll help you with that." }), + createMessage({ type: "ask", ask: "tool", partial: true }), + ]), + ) + expect(client.isStreaming()).toBe(true) + + // 4. Tool ask (complete). + client.handleMessage( + createStateMessage([ + createMessage({ + say: "api_req_started", + text: JSON.stringify({ cost: 0.001 }), + }), + createMessage({ say: "text", text: "I'll help you with that." }), + createMessage({ type: "ask", ask: "tool", partial: false }), + ]), + ) + expect(client.isWaitingForInput()).toBe(true) + expect(client.getCurrentAsk()).toBe("tool") + + // 5. User approves, task completes. + client.handleMessage( + createStateMessage([ + createMessage({ + say: "api_req_started", + text: JSON.stringify({ cost: 0.001 }), + }), + createMessage({ say: "text", text: "I'll help you with that." }), + createMessage({ type: "ask", ask: "tool", partial: false }), + createMessage({ say: "text", text: "File created." }), + createMessage({ type: "ask", ask: "completion_result", partial: false }), + ]), + ) + expect(client.getCurrentState()).toBe(AgentLoopState.IDLE) + expect(client.getCurrentAsk()).toBe("completion_result") + + // Verify we saw the expected state transitions. + expect(states).toContain(AgentLoopState.STREAMING) + expect(states).toContain(AgentLoopState.RUNNING) + expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT) + expect(states).toContain(AgentLoopState.IDLE) + }) +}) + +describe("Edge Cases", () => { + describe("Messages with missing or empty text field", () => { + it("should handle ask message with missing text field", () => { + const messages = [createMessage({ type: "ask", ask: "tool", partial: false })] + // Text is undefined by default. + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(state.currentAsk).toBe("tool") + }) + + it("should handle ask message with empty text field", () => { + const messages = [createMessage({ type: "ask", ask: "followup", partial: false, text: "" })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(state.currentAsk).toBe("followup") + }) + + it("should handle say message with missing text field", () => { + const messages = [createMessage({ say: "text" })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.RUNNING) + }) + }) + + describe("api_req_started edge cases", () => { + it("should handle api_req_started with empty text field as streaming", () => { + const messages = [createMessage({ say: "api_req_started", text: "" })] + const state = detectAgentState(messages) + // Empty text is treated as "no text yet" = still in progress (streaming). + // This matches the behavior: !message.text is true for "" (falsy). + expect(state.state).toBe(AgentLoopState.STREAMING) + expect(state.isStreaming).toBe(true) + }) + + it("should handle api_req_started with invalid JSON", () => { + const messages = [createMessage({ say: "api_req_started", text: "not valid json" })] + const state = detectAgentState(messages) + // Invalid JSON should not crash, should return not streaming. + expect(state.state).toBe(AgentLoopState.RUNNING) + expect(state.isStreaming).toBe(false) + }) + + it("should handle api_req_started with null text", () => { + const messages = [createMessage({ say: "api_req_started", text: undefined })] + const state = detectAgentState(messages) + // No text means still in progress (streaming). + expect(state.state).toBe(AgentLoopState.STREAMING) + expect(state.isStreaming).toBe(true) + }) + + it("should handle api_req_started with cost of 0", () => { + const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: 0 }) })] + const state = detectAgentState(messages) + // cost: 0 is defined (not undefined), so NOT streaming. + expect(state.state).toBe(AgentLoopState.RUNNING) + expect(state.isStreaming).toBe(false) + }) + + it("should handle api_req_started with cost of null", () => { + const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: null }) })] + const state = detectAgentState(messages) + // cost: null is defined (not undefined), so NOT streaming. + expect(state.state).toBe(AgentLoopState.RUNNING) + expect(state.isStreaming).toBe(false) + }) + + it("should find api_req_started when it's not the last message", () => { + const messages = [ + createMessage({ say: "api_req_started", text: JSON.stringify({ tokensIn: 100 }) }), // No cost = streaming + createMessage({ say: "text", text: "Some text" }), + ] + const state = detectAgentState(messages) + // Last message is say:text, but api_req_started has no cost. + expect(state.state).toBe(AgentLoopState.STREAMING) + expect(state.isStreaming).toBe(true) + }) + }) + + describe("Rapid state transitions", () => { + it("should handle multiple rapid state changes", () => { + const { client } = createMockClient() + const states: AgentLoopState[] = [] + + client.onStateChange((event) => { + states.push(event.currentState.state) + }) + + // Rapid updates. + client.handleMessage(createStateMessage([createMessage({ say: "text" })])) + client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: true })])) + client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })])) + client.handleMessage( + createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]), + ) + + // Should have tracked all transitions. + expect(states.length).toBeGreaterThanOrEqual(3) + expect(states).toContain(AgentLoopState.STREAMING) + expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT) + expect(states).toContain(AgentLoopState.IDLE) + }) + }) + + describe("Message array edge cases", () => { + it("should handle single message array", () => { + const messages = [createMessage({ say: "text", text: "Hello" })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.RUNNING) + expect(state.lastMessage).toBeDefined() + expect(state.lastMessageTs).toBe(messages[0]!.ts) + }) + + it("should use last message for state detection", () => { + // Multiple messages, last one determines state. + const messages = [ + createMessage({ type: "ask", ask: "tool", partial: false }), + createMessage({ say: "text", text: "Tool executed" }), + createMessage({ type: "ask", ask: "completion_result", partial: false }), + ] + const state = detectAgentState(messages) + // Last message is completion_result, so IDLE. + expect(state.state).toBe(AgentLoopState.IDLE) + expect(state.currentAsk).toBe("completion_result") + }) + + it("should handle very long message arrays", () => { + // Create many messages. + const messages: ClineMessage[] = [] + + for (let i = 0; i < 100; i++) { + messages.push(createMessage({ say: "text", text: `Message ${i}` })) + } + + messages.push(createMessage({ type: "ask", ask: "followup", partial: false })) + + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(state.currentAsk).toBe("followup") + }) + }) + + describe("State message edge cases", () => { + it("should handle state message with empty clineMessages", () => { + const { client } = createMockClient() + client.handleMessage({ type: "state", state: { clineMessages: [] } } as unknown as ExtensionMessage) + expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK) + expect(client.isInitialized()).toBe(true) + }) + + it("should handle state message with missing clineMessages", () => { + const { client } = createMockClient() + + client.handleMessage({ + type: "state", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: {} as any, + }) + + // Should not crash, state should remain unchanged. + expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK) + }) + + it("should handle state message with missing state field", () => { + const { client } = createMockClient() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client.handleMessage({ type: "state" } as any) + + // Should not crash + expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK) + }) + }) + + describe("Partial to complete transitions", () => { + it("should transition from streaming to waiting when partial becomes false", () => { + const ts = Date.now() + const messages1 = [createMessage({ ts, type: "ask", ask: "tool", partial: true })] + const messages2 = [createMessage({ ts, type: "ask", ask: "tool", partial: false })] + + const state1 = detectAgentState(messages1) + const state2 = detectAgentState(messages2) + + expect(state1.state).toBe(AgentLoopState.STREAMING) + expect(state1.isWaitingForInput).toBe(false) + + expect(state2.state).toBe(AgentLoopState.WAITING_FOR_INPUT) + expect(state2.isWaitingForInput).toBe(true) + }) + + it("should handle partial say messages", () => { + const messages = [createMessage({ say: "text", text: "Typing...", partial: true })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.STREAMING) + expect(state.isStreaming).toBe(true) + }) + }) + + describe("Unknown message types", () => { + it("should handle unknown ask types gracefully", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messages = [createMessage({ type: "ask", ask: "unknown_type" as any, partial: false })] + const state = detectAgentState(messages) + // Unknown ask type should default to RUNNING. + expect(state.state).toBe(AgentLoopState.RUNNING) + }) + + it("should handle unknown say types gracefully", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messages = [createMessage({ say: "unknown_say_type" as any })] + const state = detectAgentState(messages) + expect(state.state).toBe(AgentLoopState.RUNNING) + }) + }) +}) diff --git a/apps/cli/src/agent/__tests__/extension-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts new file mode 100644 index 00000000000..a0f68286e61 --- /dev/null +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -0,0 +1,760 @@ +// pnpm --filter @roo-code/cli test src/agent/__tests__/extension-host.test.ts + +import { EventEmitter } from "events" +import fs from "fs" + +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { DEFAULT_FLAGS } from "@/types/index.js" + +import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js" +import { ExtensionClient } from "../extension-client.js" +import { AgentLoopState } from "../agent-state.js" + +vi.mock("@roo-code/vscode-shim", () => ({ + createVSCodeAPI: vi.fn(() => ({ + context: { extensionPath: "/test/extension" }, + })), + setRuntimeConfigValues: vi.fn(), +})) + +vi.mock("@/lib/storage/index.js", () => ({ + createEphemeralStorageDir: vi.fn(() => Promise.resolve("/tmp/roo-cli-test-ephemeral")), +})) + +/** + * Create a test ExtensionHost with default options. + */ +function createTestHost({ + mode = "code", + provider = "openrouter", + model = "test-model", + ...options +}: Partial = {}): ExtensionHost { + return new ExtensionHost({ + mode, + user: null, + provider, + model, + workspacePath: "/test/workspace", + extensionPath: "/test/extension", + ephemeral: false, + debug: false, + exitOnComplete: false, + ...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 set private members for testing + */ +function setPrivate(host: ExtensionHost, key: string, value: unknown): void { + ;(host as unknown as PrivateHost)[key] = value +} + +/** + * Helper to call private methods for testing + * This uses a more permissive type to avoid TypeScript errors with private methods + */ +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", () => { + const initialRooCliRuntimeEnv = process.env.ROO_CLI_RUNTIME + + beforeEach(() => { + vi.resetAllMocks() + if (initialRooCliRuntimeEnv === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = initialRooCliRuntimeEnv + } + // Clean up globals + delete (global as Record).vscode + delete (global as Record).__extensionHost + }) + + afterAll(() => { + if (initialRooCliRuntimeEnv === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = initialRooCliRuntimeEnv + } + }) + + describe("constructor", () => { + it("should store options correctly", () => { + const options: ExtensionHostOptions = { + mode: "code", + workspacePath: "/my/workspace", + extensionPath: "/my/extension", + user: null, + apiKey: "test-key", + provider: "openrouter", + model: "test-model", + ephemeral: false, + debug: false, + exitOnComplete: false, + integrationTest: true, // Set explicitly for testing + } + + const host = new ExtensionHost(options) + + // Options are stored as-is + const storedOptions = getPrivate(host, "options") + expect(storedOptions.mode).toBe(options.mode) + expect(storedOptions.workspacePath).toBe(options.workspacePath) + expect(storedOptions.extensionPath).toBe(options.extensionPath) + expect(storedOptions.integrationTest).toBe(true) + }) + + 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, "isReady")).toBe(false) + expect(getPrivate(host, "vscode")).toBeNull() + expect(getPrivate(host, "extensionModule")).toBeNull() + }) + + it("should initialize managers", () => { + const host = createTestHost() + + // Should have client, outputManager, promptManager, and askDispatcher + expect(getPrivate(host, "client")).toBeDefined() + expect(getPrivate(host, "outputManager")).toBeDefined() + expect(getPrivate(host, "promptManager")).toBeDefined() + expect(getPrivate(host, "askDispatcher")).toBeDefined() + }) + + it("should mark process as CLI runtime", () => { + delete process.env.ROO_CLI_RUNTIME + createTestHost() + expect(process.env.ROO_CLI_RUNTIME).toBe("1") + }) + + it("should set execaShellPath in initialSettings when terminalShell is provided", () => { + const host = createTestHost({ terminalShell: "/bin/bash" }) + const emitSpy = vi.spyOn(host, "emit") + host.markWebviewReady() + const updateSettingsCall = emitSpy.mock.calls.find( + (call) => + call[0] === "webviewMessage" && + typeof call[1] === "object" && + call[1] !== null && + (call[1] as WebviewMessage).type === "updateSettings", + ) + expect(updateSettingsCall).toBeDefined() + const payload = updateSettingsCall?.[1] as WebviewMessage + expect(payload.updatedSettings?.execaShellPath).toBe("/bin/bash") + }) + }) + + describe("webview provider registration", () => { + it("should register webview provider without throwing", () => { + const host = createTestHost() + const mockProvider = { resolveWebviewView: vi.fn() } + + // registerWebviewProvider is now a no-op, just ensure it doesn't throw + expect(() => { + host.registerWebviewProvider("test-view", mockProvider) + }).not.toThrow() + }) + + it("should unregister webview provider without throwing", () => { + const host = createTestHost() + const mockProvider = { resolveWebviewView: vi.fn() } + + host.registerWebviewProvider("test-view", mockProvider) + + // unregisterWebviewProvider is now a no-op, just ensure it doesn't throw + expect(() => { + host.unregisterWebviewProvider("test-view") + }).not.toThrow() + }) + + 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 isReady to true", () => { + const host = createTestHost() + host.markWebviewReady() + expect(getPrivate(host, "isReady")).toBe(true) + }) + + it("should send webviewDidLaunch message", () => { + const host = createTestHost() + const emitSpy = vi.spyOn(host, "emit") + + host.markWebviewReady() + + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "webviewDidLaunch" }) + }) + + it("should send updateSettings message", () => { + const host = createTestHost() + const emitSpy = vi.spyOn(host, "emit") + + host.markWebviewReady() + + // Check that updateSettings was called + const updateSettingsCall = emitSpy.mock.calls.find( + (call) => + call[0] === "webviewMessage" && + typeof call[1] === "object" && + call[1] !== null && + (call[1] as WebviewMessage).type === "updateSettings", + ) + expect(updateSettingsCall).toBeDefined() + }) + + it("should force terminalShellIntegrationDisabled when terminalShell is provided", () => { + const host = createTestHost({ terminalShell: "/bin/bash" }) + const emitSpy = vi.spyOn(host, "emit") + + host.markWebviewReady() + + const updateSettingsCall = emitSpy.mock.calls.find( + (call) => + call[0] === "webviewMessage" && + typeof call[1] === "object" && + call[1] !== null && + (call[1] as WebviewMessage).type === "updateSettings", + ) + + expect(updateSettingsCall).toBeDefined() + const payload = updateSettingsCall?.[1] as WebviewMessage + expect(payload.type).toBe("updateSettings") + expect(payload.updatedSettings?.terminalShellIntegrationDisabled).toBe(true) + }) + }) + }) + + describe("sendToExtension", () => { + it("should throw error when extension not ready", () => { + const host = createTestHost() + const message: WebviewMessage = { type: "requestModes" } + + expect(() => { + host.sendToExtension(message) + }).toThrow("You cannot send messages to the extension before it is ready") + }) + + it("should emit webviewMessage event when webview is ready", () => { + const host = createTestHost() + const emitSpy = vi.spyOn(host, "emit") + const message: WebviewMessage = { type: "requestModes" } + + host.markWebviewReady() + emitSpy.mockClear() // Clear the markWebviewReady calls + host.sendToExtension(message) + + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", message) + }) + + it("should not throw when webview is ready", () => { + const host = createTestHost() + + host.markWebviewReady() + + expect(() => { + host.sendToExtension({ type: "requestModes" }) + }).not.toThrow() + }) + }) + + describe("message handling via client", () => { + it("should forward extension messages to the client", () => { + const host = createTestHost() + const client = getPrivate(host, "client") as ExtensionClient + + // Simulate extension message. + host.emit("extensionWebviewMessage", { + type: "state", + state: { clineMessages: [] }, + } as unknown as ExtensionMessage) + + // Message listener is set up in activate(), which we can't easily call in unit tests. + // But we can verify the client exists and has the handleMessage method. + expect(typeof client.handleMessage).toBe("function") + }) + }) + + describe("public agent state API", () => { + it("should return agent state from getAgentState()", () => { + const host = createTestHost() + const state = host.getAgentState() + + expect(state).toBeDefined() + expect(state.state).toBeDefined() + expect(state.isWaitingForInput).toBeDefined() + expect(state.isRunning).toBeDefined() + }) + + it("should return isWaitingForInput() status", () => { + const host = createTestHost() + expect(typeof host.isWaitingForInput()).toBe("boolean") + }) + }) + + describe("quiet mode", () => { + describe("setupQuietMode", () => { + it("should not modify console when integrationTest is true", () => { + // By default, constructor sets integrationTest = true + const host = createTestHost() + const originalLog = console.log + + callPrivate(host, "setupQuietMode") + + // Console should not be modified since integrationTest is true + expect(console.log).toBe(originalLog) + }) + + it("should suppress console when integrationTest is false", () => { + // Capture the real console.log before any host is created + const originalLog = console.log + + // Create host with integrationTest: true to prevent constructor from suppressing + const host = createTestHost({ integrationTest: true }) + + // Override integrationTest to false to test suppression + const options = getPrivate(host, "options") + options.integrationTest = false + + callPrivate(host, "setupQuietMode") + + // Console should be modified (suppressed) + expect(console.log).not.toBe(originalLog) + + // Restore for other tests + callPrivate(host, "restoreConsole") + }) + + it("should preserve console.error even when suppressing", () => { + const host = createTestHost() + const originalError = console.error + + // Override integrationTest to false + const options = getPrivate(host, "options") + options.integrationTest = false + + callPrivate(host, "setupQuietMode") + + expect(console.error).toBe(originalError) + + callPrivate(host, "restoreConsole") + }) + }) + + describe("restoreConsole", () => { + it("should restore original console methods when suppressed", () => { + // Capture the real console.log before any host is created + const originalLog = console.log + + // Create host with integrationTest: true to prevent constructor from suppressing + const host = createTestHost({ integrationTest: true }) + + // Override integrationTest to false to actually suppress + const options = getPrivate(host, "options") + options.integrationTest = false + + callPrivate(host, "setupQuietMode") + callPrivate(host, "restoreConsole") + + expect(console.log).toBe(originalLog) + }) + + it("should handle case where console was not suppressed", () => { + const host = createTestHost() + + expect(() => { + callPrivate(host, "restoreConsole") + }).not.toThrow() + }) + }) + }) + + describe("dispose", () => { + let host: ExtensionHost + + beforeEach(() => { + host = createTestHost() + }) + + it("should remove message listener", async () => { + const listener = vi.fn() + setPrivate(host, "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() + setPrivate(host, "extensionModule", { + deactivate: deactivateMock, + }) + + await host.dispose() + + expect(deactivateMock).toHaveBeenCalled() + }) + + it("should clear vscode reference", async () => { + setPrivate(host, "vscode", { context: {} }) + + await host.dispose() + + expect(getPrivate(host, "vscode")).toBeNull() + }) + + it("should clear extensionModule reference", async () => { + setPrivate(host, "extensionModule", {}) + + await host.dispose() + + expect(getPrivate(host, "extensionModule")).toBeNull() + }) + + 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 call restoreConsole", async () => { + const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole") + + await host.dispose() + + expect(restoreConsoleSpy).toHaveBeenCalled() + }) + + it("should clear ROO_CLI_RUNTIME on dispose when it was previously unset", async () => { + delete process.env.ROO_CLI_RUNTIME + host = createTestHost() + expect(process.env.ROO_CLI_RUNTIME).toBe("1") + + await host.dispose() + + expect(process.env.ROO_CLI_RUNTIME).toBeUndefined() + }) + + it("should restore prior ROO_CLI_RUNTIME value on dispose", async () => { + process.env.ROO_CLI_RUNTIME = "preexisting-value" + host = createTestHost() + expect(process.env.ROO_CLI_RUNTIME).toBe("1") + + await host.dispose() + + expect(process.env.ROO_CLI_RUNTIME).toBe("preexisting-value") + }) + }) + + describe("runTask", () => { + it("should send newTask message when called", async () => { + const host = createTestHost() + host.markWebviewReady() + + const emitSpy = vi.spyOn(host, "emit") + const client = getPrivate(host, "client") as ExtensionClient + + // Start the task (will hang waiting for completion) + const taskPromise = host.runTask("test prompt") + + // Emit completion to resolve the promise via the client's emitter + const taskCompletedEvent = { + success: true, + stateInfo: { + state: AgentLoopState.IDLE, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_task" as const, + description: "Task completed", + }, + } + setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10) + + await taskPromise + + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" }) + }) + + it("should include taskId when provided", async () => { + const host = createTestHost() + host.markWebviewReady() + + const emitSpy = vi.spyOn(host, "emit") + const client = getPrivate(host, "client") as ExtensionClient + + const taskPromise = host.runTask("test prompt", "task-123") + + const taskCompletedEvent = { + success: true, + stateInfo: { + state: AgentLoopState.IDLE, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_task" as const, + description: "Task completed", + }, + } + setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10) + + await taskPromise + + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { + type: "newTask", + text: "test prompt", + taskId: "task-123", + }) + }) + + it("should resolve when taskCompleted is emitted on client", async () => { + const host = createTestHost() + host.markWebviewReady() + + const client = getPrivate(host, "client") as ExtensionClient + const taskPromise = host.runTask("test prompt") + + // Emit completion after a short delay via the client's emitter + const taskCompletedEvent = { + success: true, + stateInfo: { + state: AgentLoopState.IDLE, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_task" as const, + description: "Task completed", + }, + } + setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10) + + await expect(taskPromise).resolves.toBeUndefined() + }) + + it("should send showTaskWithId for resumeTask and resolve on completion", async () => { + const host = createTestHost() + host.markWebviewReady() + + const emitSpy = vi.spyOn(host, "emit") + const client = getPrivate(host, "client") as ExtensionClient + + const taskPromise = host.resumeTask("task-abc") + + const taskCompletedEvent = { + success: true, + stateInfo: { + state: AgentLoopState.IDLE, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_task" as const, + description: "Task completed", + }, + } + setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10) + + await taskPromise + + expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "showTaskWithId", text: "task-abc" }) + }) + }) + + describe("initial settings", () => { + it("should set mode from options", () => { + const host = createTestHost({ mode: "architect" }) + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.mode).toBe("architect") + }) + + it("should use default consecutiveMistakeLimit when not provided", () => { + const host = createTestHost() + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.consecutiveMistakeLimit).toBe(DEFAULT_FLAGS.consecutiveMistakeLimit) + }) + + it("should set consecutiveMistakeLimit from options", () => { + const host = createTestHost({ consecutiveMistakeLimit: 8 }) + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.consecutiveMistakeLimit).toBe(8) + }) + + it("should enable auto-approval in non-interactive mode", () => { + const host = createTestHost({ nonInteractive: true }) + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.autoApprovalEnabled).toBe(true) + expect(initialSettings.alwaysAllowReadOnly).toBe(true) + expect(initialSettings.alwaysAllowWrite).toBe(true) + expect(initialSettings.alwaysAllowExecute).toBe(true) + }) + + it("should disable auto-approval in interactive mode", () => { + const host = createTestHost({ nonInteractive: false }) + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.autoApprovalEnabled).toBe(false) + }) + + it("should set reasoning effort when specified", () => { + const host = createTestHost({ reasoningEffort: "high" }) + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.enableReasoningEffort).toBe(true) + expect(initialSettings.reasoningEffort).toBe("high") + }) + + it("should disable reasoning effort when set to disabled", () => { + const host = createTestHost({ reasoningEffort: "disabled" }) + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.enableReasoningEffort).toBe(false) + }) + + it("should not set reasoning effort when unspecified", () => { + const host = createTestHost({ reasoningEffort: "unspecified" }) + + const initialSettings = getPrivate>(host, "initialSettings") + expect(initialSettings.enableReasoningEffort).toBeUndefined() + expect(initialSettings.reasoningEffort).toBeUndefined() + }) + }) + + describe("ephemeral mode", () => { + it("should store ephemeral option correctly", () => { + const host = createTestHost({ ephemeral: true }) + + const options = getPrivate(host, "options") + expect(options.ephemeral).toBe(true) + }) + + it("should default ephemeralStorageDir to null", () => { + const host = createTestHost() + + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() + }) + + it("should clean up ephemeral storage directory on dispose", async () => { + const host = createTestHost({ ephemeral: true }) + + // Set up a mock ephemeral storage directory + const mockEphemeralDir = "/tmp/roo-cli-test-ephemeral-cleanup" + setPrivate(host, "ephemeralStorageDir", mockEphemeralDir) + + // Mock fs.promises.rm + const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined) + + await host.dispose() + + expect(rmMock).toHaveBeenCalledWith(mockEphemeralDir, { recursive: true, force: true }) + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() + + rmMock.mockRestore() + }) + + it("should not clean up when ephemeralStorageDir is null", async () => { + const host = createTestHost() + + // ephemeralStorageDir is null by default + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() + + const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined) + + await host.dispose() + + // rm should not be called when there's no ephemeral storage + expect(rmMock).not.toHaveBeenCalled() + + rmMock.mockRestore() + }) + + it("should handle ephemeral storage cleanup errors gracefully", async () => { + const host = createTestHost({ ephemeral: true }) + + // Set up a mock ephemeral storage directory + setPrivate(host, "ephemeralStorageDir", "/tmp/roo-cli-test-ephemeral-error") + + // Mock fs.promises.rm to throw an error + const rmMock = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("Cleanup failed")) + + // dispose should not throw even if cleanup fails + await expect(host.dispose()).resolves.toBeUndefined() + + rmMock.mockRestore() + }) + + it("should not affect normal mode when ephemeral is false", () => { + const host = createTestHost({ ephemeral: false }) + + const options = getPrivate(host, "options") + expect(options.ephemeral).toBe(false) + expect(getPrivate(host, "ephemeralStorageDir")).toBeNull() + }) + }) +}) diff --git a/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts b/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts new file mode 100644 index 00000000000..8d45538ce34 --- /dev/null +++ b/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts @@ -0,0 +1,170 @@ +import { Writable } from "stream" + +import { JsonEventEmitter } from "../json-event-emitter.js" + +function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record[] } { + const chunks: string[] = [] + + const writable = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()) + callback() + }, + }) as unknown as NodeJS.WriteStream + + // Each write is a JSON line terminated by \n + const lines = () => + chunks + .join("") + .split("\n") + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as Record) + + return { stdout: writable, lines } +} + +describe("JsonEventEmitter control events", () => { + describe("emitControl", () => { + it("emits an ack event with type control", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "ack", + requestId: "req-1", + command: "start", + content: "starting task", + code: "accepted", + success: true, + }) + + const output = lines() + expect(output).toHaveLength(1) + expect(output[0]!).toMatchObject({ + type: "control", + subtype: "ack", + requestId: "req-1", + command: "start", + content: "starting task", + code: "accepted", + success: true, + }) + expect(output[0]!.done).toBeUndefined() + }) + + it("sets done: true for done events", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "done", + requestId: "req-2", + command: "start", + content: "task completed", + code: "task_completed", + success: true, + }) + + const output = lines() + expect(output[0]!).toMatchObject({ type: "control", subtype: "done", done: true }) + }) + + it("does not set done for error events", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "error", + requestId: "req-3", + command: "start", + content: "something went wrong", + code: "task_error", + success: false, + }) + + const output = lines() + expect(output[0]!.done).toBeUndefined() + expect(output[0]!.success).toBe(false) + }) + }) + + describe("requestIdProvider", () => { + it("injects requestId from provider when event has none", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => "injected-id", + }) + + emitter.emitControl({ subtype: "ack", content: "test" }) + + const output = lines() + expect(output[0]!.requestId).toBe("injected-id") + }) + + it("keeps explicit requestId when provider also returns one", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => "provider-id", + }) + + emitter.emitControl({ subtype: "ack", requestId: "explicit-id", content: "test" }) + + const output = lines() + expect(output[0]!.requestId).toBe("explicit-id") + }) + + it("omits requestId when provider returns undefined and event has none", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => undefined, + }) + + emitter.emitControl({ subtype: "ack", content: "test" }) + + const output = lines() + expect(output[0]!).not.toHaveProperty("requestId") + }) + }) + + describe("emitInit", () => { + it("emits system init with default schema values", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + // emitInit requires a client — we call emitControl to test init-like fields instead. + // emitInit is called internally by attach(), so we test the init fields via options. + // Instead, directly verify the constructor defaults by emitting a control event + // and checking that the emitter was created with correct defaults. + + // We can't call emitInit without a client, but we can verify the options + // were stored correctly by checking what emitControl produces. + emitter.emitControl({ subtype: "ack", content: "test" }) + + // The control event itself doesn't include schema fields, but at least + // we verify the emitter was constructed successfully with defaults. + const output = lines() + expect(output).toHaveLength(1) + }) + + it("accepts custom schemaVersion, protocol, and capabilities", () => { + const { stdout } = createMockStdout() + + // Should not throw when constructed with custom values + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + schemaVersion: 2, + protocol: "custom-protocol", + capabilities: ["stdin:start", "stdin:message"], + }) + + expect(emitter).toBeDefined() + }) + }) +}) diff --git a/apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts b/apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts new file mode 100644 index 00000000000..2be7adcbb53 --- /dev/null +++ b/apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts @@ -0,0 +1,129 @@ +import type { ClineMessage } from "@roo-code/types" +import { Writable } from "stream" + +import type { TaskCompletedEvent } from "../events.js" +import { JsonEventEmitter } from "../json-event-emitter.js" +import { AgentLoopState, type AgentStateInfo } from "../agent-state.js" + +function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record[] } { + const chunks: string[] = [] + + const writable = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()) + callback() + }, + }) as unknown as NodeJS.WriteStream + + const lines = () => + chunks + .join("") + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record) + + return { stdout: writable, lines } +} + +function emitMessage(emitter: JsonEventEmitter, message: ClineMessage): void { + ;(emitter as unknown as { handleMessage: (msg: ClineMessage, isUpdate: boolean) => void }).handleMessage( + message, + false, + ) +} + +function emitTaskCompleted(emitter: JsonEventEmitter, event: TaskCompletedEvent): void { + ;(emitter as unknown as { handleTaskCompleted: (taskCompleted: TaskCompletedEvent) => void }).handleTaskCompleted( + event, + ) +} + +function createAskCompletionMessage(ts: number, text = ""): ClineMessage { + return { + ts, + type: "ask", + ask: "completion_result", + partial: false, + text, + } as ClineMessage +} + +function createCompletedStateInfo(message: ClineMessage): AgentStateInfo { + return { + state: AgentLoopState.IDLE, + isWaitingForInput: true, + isRunning: false, + isStreaming: false, + currentAsk: "completion_result", + requiredAction: "start_task", + lastMessageTs: message.ts, + lastMessage: message, + description: "Task completed successfully. You can provide feedback or start a new task.", + } +} + +describe("JsonEventEmitter result emission", () => { + it("prefers current completion message content over stale cached completion text", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitMessage(emitter, { + ts: 100, + type: "say", + say: "completion_result", + partial: false, + text: "FIRST", + } as ClineMessage) + + const firstCompletionMessage = createAskCompletionMessage(101, "") + emitTaskCompleted(emitter, { + success: true, + stateInfo: createCompletedStateInfo(firstCompletionMessage), + message: firstCompletionMessage, + }) + + const secondCompletionMessage = createAskCompletionMessage(102, "SECOND") + emitTaskCompleted(emitter, { + success: true, + stateInfo: createCompletedStateInfo(secondCompletionMessage), + message: secondCompletionMessage, + }) + + const output = lines().filter((line) => line.type === "result") + expect(output).toHaveLength(2) + expect(output[0]?.content).toBe("FIRST") + expect(output[1]?.content).toBe("SECOND") + }) + + it("clears cached completion text after each result emission", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitMessage(emitter, { + ts: 200, + type: "say", + say: "completion_result", + partial: false, + text: "FIRST", + } as ClineMessage) + + const firstCompletionMessage = createAskCompletionMessage(201, "") + emitTaskCompleted(emitter, { + success: true, + stateInfo: createCompletedStateInfo(firstCompletionMessage), + message: firstCompletionMessage, + }) + + const secondCompletionMessage = createAskCompletionMessage(202, "") + emitTaskCompleted(emitter, { + success: true, + stateInfo: createCompletedStateInfo(secondCompletionMessage), + message: secondCompletionMessage, + }) + + const output = lines().filter((line) => line.type === "result") + expect(output).toHaveLength(2) + expect(output[0]?.content).toBe("FIRST") + expect(output[1]).not.toHaveProperty("content") + }) +}) diff --git a/apps/cli/src/agent/__tests__/json-event-emitter-streaming.test.ts b/apps/cli/src/agent/__tests__/json-event-emitter-streaming.test.ts new file mode 100644 index 00000000000..6253fbdec6d --- /dev/null +++ b/apps/cli/src/agent/__tests__/json-event-emitter-streaming.test.ts @@ -0,0 +1,389 @@ +import type { ClineMessage } from "@roo-code/types" +import { Writable } from "stream" + +import { JsonEventEmitter } from "../json-event-emitter.js" + +function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record[] } { + const chunks: string[] = [] + + const writable = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()) + callback() + }, + }) as unknown as NodeJS.WriteStream + + const lines = () => + chunks + .join("") + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record) + + return { stdout: writable, lines } +} + +function emitMessage(emitter: JsonEventEmitter, message: ClineMessage): void { + ;(emitter as unknown as { handleMessage: (msg: ClineMessage, isUpdate: boolean) => void }).handleMessage( + message, + false, + ) +} + +function createAskMessage(overrides: Partial): ClineMessage { + return { + ts: 1, + type: "ask", + ask: "tool", + partial: true, + text: "", + ...overrides, + } as ClineMessage +} + +describe("JsonEventEmitter streaming deltas", () => { + it("streams ask:command partial updates as deltas and emits full final snapshot", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const id = 101 + + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "g", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh pr", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: false, + text: "gh pr", + }), + ) + + const output = lines() + expect(output).toHaveLength(4) + expect(output[0]).toMatchObject({ + type: "tool_use", + id, + subtype: "command", + content: "g", + tool_use: { name: "execute_command", input: { command: "g" } }, + }) + expect(output[1]).toMatchObject({ + type: "tool_use", + id, + subtype: "command", + content: "h", + tool_use: { name: "execute_command", input: { command: "h" } }, + }) + expect(output[2]).toMatchObject({ + type: "tool_use", + id, + subtype: "command", + content: " pr", + tool_use: { name: "execute_command", input: { command: " pr" } }, + }) + expect(output[3]).toMatchObject({ + type: "tool_use", + id, + subtype: "command", + tool_use: { name: "execute_command", input: { command: "gh pr" } }, + done: true, + }) + expect(output[3]).not.toHaveProperty("content") + }) + + it("streams ask:tool snapshots as structured deltas and preserves full final payload", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const id = 202 + const first = JSON.stringify({ tool: "readFile", path: "a" }) + const second = JSON.stringify({ tool: "readFile", path: "ab" }) + + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "tool", + partial: true, + text: first, + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "tool", + partial: true, + text: second, + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "tool", + partial: false, + text: second, + }), + ) + + const output = lines() + expect(output).toHaveLength(3) + expect(output[0]).toMatchObject({ + type: "tool_use", + id, + subtype: "tool", + content: first, + tool_use: { name: "readFile" }, + }) + expect(output[1]).toMatchObject({ + type: "tool_use", + id, + subtype: "tool", + content: "b", + tool_use: { name: "readFile" }, + }) + expect(output[2]).toMatchObject({ + type: "tool_use", + id, + subtype: "tool", + tool_use: { name: "readFile", input: { tool: "readFile", path: "ab" } }, + done: true, + }) + }) + + it("suppresses duplicate partial tool snapshots with no delta", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const id = 303 + + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh", + }), + ) + emitMessage( + emitter, + createAskMessage({ + ts: id, + ask: "command", + partial: true, + text: "gh pr", + }), + ) + + const output = lines() + expect(output).toHaveLength(2) + expect(output[0]).toMatchObject({ content: "gh" }) + expect(output[1]).toMatchObject({ content: " pr" }) + }) + + it("streams say:command_output as deltas and correlates tool_result id to execute_command", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const commandId = 404 + const outputTs = 405 + + emitMessage( + emitter, + createAskMessage({ + ts: commandId, + ask: "command", + partial: false, + text: "echo hello", + }), + ) + + emitMessage(emitter, { + ts: outputTs, + type: "say", + say: "command_output", + partial: true, + text: "line1\n", + } as ClineMessage) + emitMessage(emitter, { + ts: outputTs, + type: "say", + say: "command_output", + partial: true, + text: "line1\nline2\n", + } as ClineMessage) + emitMessage(emitter, { + ts: outputTs, + type: "say", + say: "command_output", + partial: false, + text: "line1\nline2\n", + } as ClineMessage) + + const output = lines() + expect(output).toHaveLength(4) + expect(output[0]).toMatchObject({ + type: "tool_use", + id: commandId, + subtype: "command", + tool_use: { name: "execute_command", input: { command: "echo hello" } }, + done: true, + }) + expect(output[1]).toMatchObject({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command", output: "line1\n" }, + }) + expect(output[2]).toMatchObject({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command", output: "line2\n" }, + }) + expect(output[3]).toMatchObject({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command" }, + done: true, + }) + expect(output[3]).not.toHaveProperty("tool_result.output") + }) + + it("prefers status-driven command output streaming and suppresses duplicate say completion", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const commandId = 505 + + emitMessage( + emitter, + createAskMessage({ + ts: commandId, + ask: "command", + partial: false, + text: "echo streamed", + }), + ) + + emitter.emitCommandOutputChunk("line1\n") + emitter.emitCommandOutputChunk("line1\nline2\n") + emitter.markCommandOutputExited(17) + + // This completion say is expected from the extension and should finalize + // the status-driven command_output stream without duplicating content. + emitMessage(emitter, { + ts: 999, + type: "say", + say: "command_output", + partial: false, + text: "line1\nline2\n", + } as ClineMessage) + + const output = lines() + expect(output).toHaveLength(4) + expect(output[0]).toMatchObject({ + type: "tool_use", + id: commandId, + subtype: "command", + tool_use: { name: "execute_command", input: { command: "echo streamed" } }, + done: true, + }) + expect(output[1]).toMatchObject({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command", output: "line1\n" }, + }) + expect(output[2]).toMatchObject({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command", output: "line2\n" }, + }) + expect(output[3]).toMatchObject({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command", exitCode: 17 }, + done: true, + }) + }) + + it("flushes remaining output on final say completion after fast status:exited", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + const commandId = 606 + + emitMessage( + emitter, + createAskMessage({ + ts: commandId, + ask: "command", + partial: false, + text: "aws sts get-caller-identity", + }), + ) + + emitter.emitCommandOutputChunk("{\n") + emitter.markCommandOutputExited(0) + + emitMessage(emitter, { + ts: 607, + type: "say", + say: "command_output", + partial: false, + text: '{\n "Account": "123"\n}\n', + } as ClineMessage) + + const output = lines() + expect(output).toHaveLength(3) + expect(output[1]).toMatchObject({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command", output: "{\n" }, + }) + expect(output[2]).toMatchObject({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command", output: ' "Account": "123"\n}\n', exitCode: 0 }, + done: true, + }) + }) +}) diff --git a/apps/cli/src/agent/agent-state.ts b/apps/cli/src/agent/agent-state.ts new file mode 100644 index 00000000000..d1451d62fdd --- /dev/null +++ b/apps/cli/src/agent/agent-state.ts @@ -0,0 +1,463 @@ +/** + * Agent Loop State Detection + * + * This module provides the core logic for detecting the current state of the + * Roo Code agent loop. The state is determined by analyzing the clineMessages + * array, specifically the last message's type and properties. + * + * Key insight: The agent loop stops whenever a message with `type: "ask"` arrives, + * and the specific `ask` value determines what kind of response the agent is waiting for. + */ + +import { ClineMessage, ClineAsk, isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk } from "@roo-code/types" + +// ============================================================================= +// Agent Loop State Enum +// ============================================================================= + +/** + * The possible states of the agent loop. + * + * State Machine: + * ``` + * ┌─────────────────┐ + * │ NO_TASK │ (initial state) + * └────────┬────────┘ + * │ newTask + * ▼ + * ┌─────────────────────────────┐ + * ┌───▶│ RUNNING │◀───┐ + * │ └──────────┬──────────────────┘ │ + * │ │ │ + * │ ┌──────────┼──────────────┐ │ + * │ │ │ │ │ + * │ ▼ ▼ ▼ │ + * │ ┌──────┐ ┌─────────┐ ┌──────────┐ │ + * │ │STREAM│ │INTERACT │ │ IDLE │ │ + * │ │ ING │ │ IVE │ │ │ │ + * │ └──┬───┘ └────┬────┘ └────┬─────┘ │ + * │ │ │ │ │ + * │ │ done │ approved │ newTask │ + * └────┴───────────┴────────────┘ │ + * │ + * ┌──────────────┐ │ + * │ RESUMABLE │────────────────────────┘ + * └──────────────┘ resumed + * ``` + */ +export enum AgentLoopState { + /** + * No active task. This is the initial state before any task is started, + * or after a task has been cleared. + */ + NO_TASK = "no_task", + + /** + * Agent is actively processing. This means: + * - The last message is a "say" type (informational), OR + * - The last message is a non-blocking ask (command_output) + * + * In this state, the agent may be: + * - Executing tools + * - Thinking/reasoning + * - Processing between API calls + */ + RUNNING = "running", + + /** + * Agent is streaming a response. This is detected when: + * - `partial === true` on the last message, OR + * - The last `api_req_started` message has no `cost` in its text field + * + * Do NOT consider the agent "waiting" while streaming. + */ + STREAMING = "streaming", + + /** + * Agent is waiting for user approval or input. This includes: + * - Tool approvals (file operations) + * - Command execution permission + * - Browser action permission + * - MCP server permission + * - Follow-up questions + * + * User must approve, reject, or provide input to continue. + */ + WAITING_FOR_INPUT = "waiting_for_input", + + /** + * Task is in an idle/terminal state. This includes: + * - Task completed successfully (completion_result) + * - API request failed (api_req_failed) + * - Too many errors (mistake_limit_reached) + * - Auto-approval limit reached + * - Completed task waiting to be resumed + * + * User can start a new task or retry. + */ + IDLE = "idle", + + /** + * Task is paused and can be resumed. This happens when: + * - User navigated away from a task + * - Extension was restarted mid-task + * + * User can resume or abandon the task. + */ + RESUMABLE = "resumable", +} + +// ============================================================================= +// Detailed State Info +// ============================================================================= + +/** + * What action the user should/can take in the current state. + */ +export type RequiredAction = + | "none" // No action needed (running/streaming) + | "approve" // Can approve/reject (tool, command, mcp) + | "answer" // Need to answer a question (followup) + | "retry_or_new_task" // Can retry or start new task (api_req_failed) + | "proceed_or_new_task" // Can proceed or start new task (mistake_limit) + | "start_task" // Should start a new task (completion_result) + | "resume_or_abandon" // Can resume or abandon (resume_task) + | "start_new_task" // Should start new task (resume_completed_task, no_task) + | "continue_or_abort" // Can continue or abort (command_output) + +/** + * Detailed information about the current agent state. + * Provides everything needed to render UI or make decisions. + */ +export interface AgentStateInfo { + /** The high-level state of the agent loop */ + state: AgentLoopState + + /** Whether the agent is waiting for user input/action */ + isWaitingForInput: boolean + + /** Whether the agent loop is actively processing */ + isRunning: boolean + + /** Whether content is being streamed */ + isStreaming: boolean + + /** The specific ask type if waiting on an ask, undefined otherwise */ + currentAsk?: ClineAsk + + /** What action the user should/can take */ + requiredAction: RequiredAction + + /** The timestamp of the last message, useful for tracking */ + lastMessageTs?: number + + /** The full last message for advanced usage */ + lastMessage?: ClineMessage + + /** Human-readable description of the current state */ + description: string +} + +// ============================================================================= +// State Detection Functions +// ============================================================================= + +/** + * Structure of the text field in api_req_started messages. + * Used to determine if the API request has completed (cost is defined). + */ +export interface ApiReqStartedText { + cost?: number // Undefined while streaming, defined when complete. + tokensIn?: number + tokensOut?: number + cacheWrites?: number + cacheReads?: number +} + +/** + * Check if an API request is still in progress (streaming). + * + * API requests are considered in-progress when: + * - An api_req_started message exists + * - Its text field, when parsed, has `cost: undefined` + * + * Once the request completes, the cost field will be populated. + */ +function isApiRequestInProgress(messages: ClineMessage[]): boolean { + // Find the last api_req_started message. + // Using reverse iteration for efficiency (most recent first). + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + + if (!message) { + continue + } + + if (message.say === "api_req_started") { + if (!message.text) { + // No text yet means still in progress. + return true + } + + try { + const data: ApiReqStartedText = JSON.parse(message.text) + // cost is undefined while streaming, defined when complete. + return data.cost === undefined + } catch { + // Parse error - assume not in progress. + return false + } + } + } + return false +} + +/** + * Determine the required action based on the current ask type. + */ +function getRequiredAction(ask: ClineAsk): RequiredAction { + switch (ask) { + case "followup": + return "answer" + case "command": + case "tool": + case "use_mcp_server": + return "approve" + case "command_output": + return "continue_or_abort" + case "api_req_failed": + return "retry_or_new_task" + case "mistake_limit_reached": + return "proceed_or_new_task" + case "completion_result": + return "start_task" + case "resume_task": + return "resume_or_abandon" + case "resume_completed_task": + case "auto_approval_max_req_reached": + return "start_new_task" + default: + return "none" + } +} + +/** + * Get a human-readable description for the current state. + */ +function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string { + switch (state) { + case AgentLoopState.NO_TASK: + return "No active task. Ready to start a new task." + + case AgentLoopState.RUNNING: + return "Agent is actively processing." + + case AgentLoopState.STREAMING: + return "Agent is streaming a response." + + case AgentLoopState.WAITING_FOR_INPUT: + switch (ask) { + case "followup": + return "Agent is asking a follow-up question. Please provide an answer." + case "command": + return "Agent wants to execute a command. Approve or reject." + case "tool": + return "Agent wants to perform a file operation. Approve or reject." + case "use_mcp_server": + return "Agent wants to use an MCP server. Approve or reject." + default: + return "Agent is waiting for user input." + } + + case AgentLoopState.IDLE: + switch (ask) { + case "completion_result": + return "Task completed successfully. You can provide feedback or start a new task." + case "api_req_failed": + return "API request failed. You can retry or start a new task." + case "mistake_limit_reached": + return "Too many errors encountered. You can proceed anyway or start a new task." + case "auto_approval_max_req_reached": + return "Auto-approval limit reached. Manual approval required." + case "resume_completed_task": + return "Previously completed task. Start a new task to continue." + default: + return "Task is idle." + } + + case AgentLoopState.RESUMABLE: + return "Task is paused. You can resume or start a new task." + + default: + return "Unknown state." + } +} + +/** + * Detect the current state of the agent loop from the clineMessages array. + * + * This is the main state detection function. It analyzes the messages array + * and returns detailed information about the current agent state. + * + * @param messages - The clineMessages array from extension state + * @returns Detailed state information + */ +export function detectAgentState(messages: ClineMessage[]): AgentStateInfo { + // No messages means no task + if (!messages || messages.length === 0) { + return { + state: AgentLoopState.NO_TASK, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_new_task", + description: getStateDescription(AgentLoopState.NO_TASK), + } + } + + const lastMessage = messages[messages.length - 1] + + // Guard against undefined (should never happen after length check, but TypeScript requires it) + if (!lastMessage) { + return { + state: AgentLoopState.NO_TASK, + isWaitingForInput: false, + isRunning: false, + isStreaming: false, + requiredAction: "start_new_task", + description: getStateDescription(AgentLoopState.NO_TASK), + } + } + + // Check if the message is still streaming (partial) + // This is the PRIMARY indicator of streaming + if (lastMessage.partial === true) { + return { + state: AgentLoopState.STREAMING, + isWaitingForInput: false, + isRunning: true, + isStreaming: true, + currentAsk: lastMessage.ask, + requiredAction: "none", + lastMessageTs: lastMessage.ts, + lastMessage, + description: getStateDescription(AgentLoopState.STREAMING), + } + } + + // Handle "ask" type messages + if (lastMessage.type === "ask" && lastMessage.ask) { + const ask = lastMessage.ask + + // Non-blocking asks (command_output) - agent is running but can be interrupted + if (isNonBlockingAsk(ask)) { + return { + state: AgentLoopState.RUNNING, + isWaitingForInput: false, + isRunning: true, + isStreaming: false, + currentAsk: ask, + requiredAction: "continue_or_abort", + lastMessageTs: lastMessage.ts, + lastMessage, + description: "Command is running. You can continue or abort.", + } + } + + // Idle asks - task has stopped + if (isIdleAsk(ask)) { + return { + state: AgentLoopState.IDLE, + isWaitingForInput: true, // User needs to decide what to do next + isRunning: false, + isStreaming: false, + currentAsk: ask, + requiredAction: getRequiredAction(ask), + lastMessageTs: lastMessage.ts, + lastMessage, + description: getStateDescription(AgentLoopState.IDLE, ask), + } + } + + // Resumable asks - task is paused + if (isResumableAsk(ask)) { + return { + state: AgentLoopState.RESUMABLE, + isWaitingForInput: true, + isRunning: false, + isStreaming: false, + currentAsk: ask, + requiredAction: getRequiredAction(ask), + lastMessageTs: lastMessage.ts, + lastMessage, + description: getStateDescription(AgentLoopState.RESUMABLE, ask), + } + } + + // Interactive asks - waiting for approval/input + if (isInteractiveAsk(ask)) { + return { + state: AgentLoopState.WAITING_FOR_INPUT, + isWaitingForInput: true, + isRunning: false, + isStreaming: false, + currentAsk: ask, + requiredAction: getRequiredAction(ask), + lastMessageTs: lastMessage.ts, + lastMessage, + description: getStateDescription(AgentLoopState.WAITING_FOR_INPUT, ask), + } + } + } + + // For "say" type messages, check if API request is in progress + if (isApiRequestInProgress(messages)) { + return { + state: AgentLoopState.STREAMING, + isWaitingForInput: false, + isRunning: true, + isStreaming: true, + requiredAction: "none", + lastMessageTs: lastMessage.ts, + lastMessage, + description: getStateDescription(AgentLoopState.STREAMING), + } + } + + // Default: agent is running + return { + state: AgentLoopState.RUNNING, + isWaitingForInput: false, + isRunning: true, + isStreaming: false, + requiredAction: "none", + lastMessageTs: lastMessage.ts, + lastMessage, + description: getStateDescription(AgentLoopState.RUNNING), + } +} + +/** + * Quick check: Is the agent waiting for user input? + * + * This is a convenience function for simple use cases where you just need + * to know if user action is required. + */ +export function isAgentWaitingForInput(messages: ClineMessage[]): boolean { + return detectAgentState(messages).isWaitingForInput +} + +/** + * Quick check: Is the agent actively running (not waiting)? + */ +export function isAgentRunning(messages: ClineMessage[]): boolean { + const state = detectAgentState(messages) + return state.isRunning && !state.isWaitingForInput +} + +/** + * Quick check: Is content currently streaming? + */ +export function isContentStreaming(messages: ClineMessage[]): boolean { + return detectAgentState(messages).isStreaming +} diff --git a/apps/cli/src/agent/ask-dispatcher.ts b/apps/cli/src/agent/ask-dispatcher.ts new file mode 100644 index 00000000000..44e861ae9b8 --- /dev/null +++ b/apps/cli/src/agent/ask-dispatcher.ts @@ -0,0 +1,664 @@ +/** + * AskDispatcher - Routes ask messages to appropriate handlers + * + * This dispatcher is responsible for: + * - Categorizing ask types using type guards from client module + * - Routing to the appropriate handler based on ask category + * - Coordinating between OutputManager and PromptManager + * - Tracking which asks have been handled (to avoid duplicates) + * + * Design notes: + * - Uses isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk type guards + * - Single responsibility: Ask routing and handling only + * - Delegates output to OutputManager, input to PromptManager + * - Sends responses back through a provided callback + */ + +import { + type WebviewMessage, + type ClineMessage, + type ClineAsk, + type ClineAskResponse, + isIdleAsk, + isInteractiveAsk, + isResumableAsk, + isNonBlockingAsk, +} from "@roo-code/types" +import { debugLog } from "@roo-code/core/cli" + +import { FOLLOWUP_TIMEOUT_SECONDS } from "@/types/index.js" + +import type { OutputManager } from "./output-manager.js" +import type { PromptManager } from "./prompt-manager.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Configuration for AskDispatcher. + */ +export interface AskDispatcherOptions { + /** + * OutputManager for displaying ask-related output. + */ + outputManager: OutputManager + + /** + * PromptManager for collecting user input. + */ + promptManager: PromptManager + + /** + * Callback to send responses to the extension. + */ + sendMessage: (message: WebviewMessage) => void + + /** + * Whether running in non-interactive mode (auto-approve). + */ + nonInteractive?: boolean + + /** + * Whether to exit on API request errors instead of retrying. + */ + exitOnError?: boolean + + /** + * Whether to disable ask handling (for TUI mode). + * In TUI mode, the TUI handles asks directly. + */ + disabled?: boolean +} + +/** + * Result of handling an ask. + */ +export interface AskHandleResult { + /** Whether the ask was handled */ + handled: boolean + /** The response sent (if any) */ + response?: ClineAskResponse + /** Any error that occurred */ + error?: Error +} + +// ============================================================================= +// AskDispatcher Class +// ============================================================================= + +export class AskDispatcher { + private outputManager: OutputManager + private promptManager: PromptManager + private sendMessage: (message: WebviewMessage) => void + private nonInteractive: boolean + private exitOnError: boolean + private disabled: boolean + + /** + * Track which asks have been handled to avoid duplicates. + * Key: message ts + */ + private handledAsks = new Set() + + constructor(options: AskDispatcherOptions) { + this.outputManager = options.outputManager + this.promptManager = options.promptManager + this.sendMessage = options.sendMessage + this.nonInteractive = options.nonInteractive ?? false + this.exitOnError = options.exitOnError ?? false + this.disabled = options.disabled ?? false + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Handle an ask message. + * Routes to the appropriate handler based on ask type. + * + * @param message - The ClineMessage with type="ask" + * @returns Promise + */ + async handleAsk(message: ClineMessage): Promise { + // Disabled in TUI mode - TUI handles asks directly + if (this.disabled) { + return { handled: false } + } + + const ts = message.ts + const ask = message.ask + const text = message.text || "" + + // Check if already handled + if (this.handledAsks.has(ts)) { + return { handled: true } + } + + // Must be an ask message + if (message.type !== "ask" || !ask) { + return { handled: false } + } + + // Skip partial messages (wait for complete) + if (message.partial) { + return { handled: false } + } + + // Mark as being handled + this.handledAsks.add(ts) + + try { + // Route based on ask category + if (isNonBlockingAsk(ask)) { + return await this.handleNonBlockingAsk(ts, ask, text) + } + + if (isIdleAsk(ask)) { + return await this.handleIdleAsk(ts, ask, text) + } + + if (isResumableAsk(ask)) { + return await this.handleResumableAsk(ts, ask, text) + } + + if (isInteractiveAsk(ask)) { + return await this.handleInteractiveAsk(ts, ask, text) + } + + // Unknown ask type - log and handle generically + debugLog("[AskDispatcher] Unknown ask type", { ask, ts }) + return await this.handleUnknownAsk(ts, ask, text) + } catch (error) { + // Re-allow handling on error + this.handledAsks.delete(ts) + return { + handled: false, + error: error instanceof Error ? error : new Error(String(error)), + } + } + } + + /** + * Check if an ask has been handled. + */ + isHandled(ts: number): boolean { + return this.handledAsks.has(ts) + } + + /** + * Clear handled asks (call when starting new task). + */ + clear(): void { + this.handledAsks.clear() + } + + // =========================================================================== + // Category Handlers + // =========================================================================== + + /** + * Handle non-blocking asks (command_output). + * These don't actually block the agent - just need acknowledgment. + */ + private async handleNonBlockingAsk(_ts: number, _ask: ClineAsk, _text: string): Promise { + // command_output - output is handled by OutputManager + // Just send approval to continue + this.sendApprovalResponse(true) + return { handled: true, response: "yesButtonClicked" } + } + + /** + * Handle idle asks (completion_result, api_req_failed, etc.). + * These indicate the task has stopped. + */ + private async handleIdleAsk(ts: number, ask: ClineAsk, text: string): Promise { + switch (ask) { + case "completion_result": + // Task complete - nothing to do here, TaskCompleted event handles it + return { handled: true } + + case "api_req_failed": + return await this.handleApiFailedRetry(ts, text) + + case "mistake_limit_reached": + return await this.handleMistakeLimitReached(ts, text) + + case "resume_completed_task": + return await this.handleResumeTask(ts, ask, text) + + case "auto_approval_max_req_reached": + return await this.handleAutoApprovalMaxReached(ts, text) + + default: + return { handled: false } + } + } + + /** + * Handle resumable asks (resume_task). + */ + private async handleResumableAsk(ts: number, ask: ClineAsk, text: string): Promise { + return await this.handleResumeTask(ts, ask, text) + } + + /** + * Handle interactive asks (followup, command, tool, use_mcp_server). + * These require user approval or input. + */ + private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise { + switch (ask) { + case "followup": + return await this.handleFollowupQuestion(ts, text) + + case "command": + return await this.handleCommandApproval(ts, text) + + case "tool": + return await this.handleToolApproval(ts, text) + + case "use_mcp_server": + return await this.handleMcpApproval(ts, text) + + default: + return { handled: false } + } + } + + /** + * Handle unknown ask types. + */ + private async handleUnknownAsk(ts: number, ask: ClineAsk, text: string): Promise { + if (this.nonInteractive) { + if (text) { + this.outputManager.output(`\n[${ask}]`, text) + } + return { handled: true } + } + + return await this.handleGenericApproval(ts, ask, text) + } + + // =========================================================================== + // Specific Ask Handlers + // =========================================================================== + + /** + * Handle followup questions - prompt for text input with suggestions. + */ + private async handleFollowupQuestion(ts: number, text: string): Promise { + let question = text + let suggestions: Array<{ answer: string; mode?: string | null }> = [] + + 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.outputManager.output("\n[question]", question) + + if (suggestions.length > 0) { + this.outputManager.output("\nSuggested answers:") + suggestions.forEach((suggestion, index) => { + const suggestionText = suggestion.answer || String(suggestion) + const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" + this.outputManager.output(` ${index + 1}. ${suggestionText}${modeHint}`) + }) + this.outputManager.output("") + } + + const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null + const defaultAnswer = firstSuggestion?.answer ?? "" + + if (this.nonInteractive) { + // Use timeout prompt in non-interactive mode + const timeoutMs = FOLLOWUP_TIMEOUT_SECONDS * 1000 + const result = await this.promptManager.promptWithTimeout( + suggestions.length > 0 + ? `Enter number (1-${suggestions.length}) or type your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): ` + : `Your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `, + timeoutMs, + defaultAnswer, + ) + + let responseText = result.value.trim() + responseText = this.resolveNumberedSuggestion(responseText, suggestions) + + if (result.timedOut || result.cancelled) { + this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`) + } + + this.sendFollowupResponse(responseText) + return { handled: true, response: "messageResponse" } + } + + // Interactive mode + try { + const answer = await this.promptManager.promptForInput( + suggestions.length > 0 + ? `Enter number (1-${suggestions.length}) or type your answer: ` + : "Your answer: ", + ) + + let responseText = answer.trim() + responseText = this.resolveNumberedSuggestion(responseText, suggestions) + + this.sendFollowupResponse(responseText) + return { handled: true, response: "messageResponse" } + } catch { + this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`) + this.sendFollowupResponse(defaultAnswer) + return { handled: true, response: "messageResponse" } + } + } + + /** + * Handle command execution approval. + */ + private async handleCommandApproval(ts: number, text: string): Promise { + this.outputManager.output("\n[command request]") + this.outputManager.output(` Command: ${text || "(no command specified)"}`) + this.outputManager.markDisplayed(ts, text || "", false) + + if (this.nonInteractive) { + // Auto-approved by extension settings + return { handled: true } + } + + try { + const approved = await this.promptManager.promptForYesNo("Execute this command? (y/n): ") + this.sendApprovalResponse(approved) + return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } + } catch { + this.outputManager.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + return { handled: true, response: "noButtonClicked" } + } + } + + /** + * 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 + } + + const isProtected = toolInfo.isProtected === true + + if (isProtected) { + this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`) + this.outputManager.output(`⚠️ WARNING: This tool wants to modify a protected configuration file.`) + this.outputManager.output( + ` Protected files include .rooignore, .roo/*, and other sensitive config files.`, + ) + } else { + this.outputManager.output(`\n[Tool Request] ${toolName}`) + } + + // Display tool details + for (const [key, value] of Object.entries(toolInfo)) { + if (key === "tool" || key === "isProtected") continue + + 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.outputManager.output(` ${key}: ${displayValue}`) + } + + this.outputManager.markDisplayed(ts, text || "", false) + + if (this.nonInteractive) { + // Auto-approved by extension settings (unless protected) + return { handled: true } + } + + try { + const approved = await this.promptManager.promptForYesNo("Approve this action? (y/n): ") + this.sendApprovalResponse(approved) + return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } + } catch { + this.outputManager.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + return { handled: true, response: "noButtonClicked" } + } + } + + /** + * 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.outputManager.output("\n[mcp request]") + this.outputManager.output(` Server: ${serverName}`) + if (toolName) { + this.outputManager.output(` Tool: ${toolName}`) + } + if (resourceUri) { + this.outputManager.output(` Resource: ${resourceUri}`) + } + this.outputManager.markDisplayed(ts, text || "", false) + + if (this.nonInteractive) { + // Auto-approved by extension settings + return { handled: true } + } + + try { + const approved = await this.promptManager.promptForYesNo("Allow MCP access? (y/n): ") + this.sendApprovalResponse(approved) + return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } + } catch { + this.outputManager.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + return { handled: true, response: "noButtonClicked" } + } + } + + /** + * Handle API request failed - retry prompt. + */ + private async handleApiFailedRetry(ts: number, text: string): Promise { + this.outputManager.output("\n[api request failed]") + this.outputManager.output(` Error: ${text || "Unknown error"}`) + this.outputManager.markDisplayed(ts, text || "", false) + + if (this.exitOnError) { + console.error(`[CLI] API request failed: ${text || "Unknown error"}`) + process.exit(1) + } + + if (this.nonInteractive) { + this.outputManager.output("\n[retrying api request]") + // Auto-retry in non-interactive mode + return { handled: true } + } + + try { + const retry = await this.promptManager.promptForYesNo("Retry the request? (y/n): ") + this.sendApprovalResponse(retry) + return { handled: true, response: retry ? "yesButtonClicked" : "noButtonClicked" } + } catch { + this.outputManager.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + return { handled: true, response: "noButtonClicked" } + } + } + + /** + * Handle mistake limit reached. + */ + private async handleMistakeLimitReached(ts: number, text: string): Promise { + this.outputManager.output("\n[mistake limit reached]") + if (text) { + this.outputManager.output(` Details: ${text}`) + } + this.outputManager.markDisplayed(ts, text || "", false) + + if (this.nonInteractive) { + // Auto-proceed in non-interactive mode + this.sendApprovalResponse(true) + return { handled: true, response: "yesButtonClicked" } + } + + try { + const proceed = await this.promptManager.promptForYesNo("Continue anyway? (y/n): ") + this.sendApprovalResponse(proceed) + return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" } + } catch { + this.outputManager.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + return { handled: true, response: "noButtonClicked" } + } + } + + /** + * Handle auto-approval max reached. + */ + private async handleAutoApprovalMaxReached(ts: number, text: string): Promise { + this.outputManager.output("\n[auto-approval limit reached]") + if (text) { + this.outputManager.output(` Details: ${text}`) + } + this.outputManager.markDisplayed(ts, text || "", false) + + if (this.nonInteractive) { + // Auto-proceed in non-interactive mode + this.sendApprovalResponse(true) + return { handled: true, response: "yesButtonClicked" } + } + + try { + const proceed = await this.promptManager.promptForYesNo("Continue with manual approval? (y/n): ") + this.sendApprovalResponse(proceed) + return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" } + } catch { + this.outputManager.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + return { handled: true, response: "noButtonClicked" } + } + } + + /** + * Handle task resume prompt. + */ + private async handleResumeTask(ts: number, ask: ClineAsk, text: string): Promise { + const isCompleted = ask === "resume_completed_task" + this.outputManager.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`) + if (text) { + this.outputManager.output(` ${text}`) + } + this.outputManager.markDisplayed(ts, text || "", false) + + if (this.nonInteractive) { + this.outputManager.output("\n[continuing task]") + // Auto-resume in non-interactive mode + this.sendApprovalResponse(true) + return { handled: true, response: "yesButtonClicked" } + } + + try { + const resume = await this.promptManager.promptForYesNo("Continue with this task? (y/n): ") + this.sendApprovalResponse(resume) + return { handled: true, response: resume ? "yesButtonClicked" : "noButtonClicked" } + } catch { + this.outputManager.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + return { handled: true, response: "noButtonClicked" } + } + } + + /** + * Handle generic approval prompts for unknown ask types. + */ + private async handleGenericApproval(ts: number, ask: ClineAsk, text: string): Promise { + this.outputManager.output(`\n[${ask}]`) + if (text) { + this.outputManager.output(` ${text}`) + } + this.outputManager.markDisplayed(ts, text || "", false) + + try { + const approved = await this.promptManager.promptForYesNo("Approve? (y/n): ") + this.sendApprovalResponse(approved) + return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } + } catch { + this.outputManager.output("[Defaulting to: no]") + this.sendApprovalResponse(false) + return { handled: true, response: "noButtonClicked" } + } + } + + // =========================================================================== + // Response Helpers + // =========================================================================== + + /** + * Send a followup response (text answer) to the extension. + */ + private sendFollowupResponse(text: string): void { + this.sendMessage({ type: "askResponse", askResponse: "messageResponse", text }) + } + + /** + * Send an approval response (yes/no) to the extension. + */ + private sendApprovalResponse(approved: boolean): void { + this.sendMessage({ + type: "askResponse", + askResponse: approved ? "yesButtonClicked" : "noButtonClicked", + }) + } + + /** + * Resolve a numbered suggestion selection. + */ + private resolveNumberedSuggestion( + input: string, + suggestions: Array<{ answer: string; mode?: string | null }>, + ): string { + const num = parseInt(input, 10) + if (!isNaN(num) && num >= 1 && num <= suggestions.length) { + const selectedSuggestion = suggestions[num - 1] + if (selectedSuggestion) { + const selected = selectedSuggestion.answer || String(selectedSuggestion) + this.outputManager.output(`Selected: ${selected}`) + return selected + } + } + return input + } +} diff --git a/apps/cli/src/agent/events.ts b/apps/cli/src/agent/events.ts new file mode 100644 index 00000000000..f455bf0c9de --- /dev/null +++ b/apps/cli/src/agent/events.ts @@ -0,0 +1,372 @@ +/** + * Event System for Agent State Changes + * + * This module provides a strongly-typed event emitter specifically designed + * for tracking agent state changes. It uses Node.js EventEmitter under the hood + * but provides type safety for all events. + */ + +import { EventEmitter } from "events" + +import { ClineMessage, ClineAsk } from "@roo-code/types" + +import type { AgentStateInfo } from "./agent-state.js" + +// ============================================================================= +// Event Types +// ============================================================================= + +/** + * All events that can be emitted by the client. + * + * Design note: We use a string literal union type for event names to ensure + * type safety when subscribing to events. The payload type is determined by + * the event name. + */ +export interface ClientEventMap { + /** + * Emitted whenever the agent state changes. + * This is the primary event for tracking state. + */ + stateChange: AgentStateChangeEvent + + /** + * Emitted when a new message is added to the message list. + */ + message: ClineMessage + + /** + * Emitted when an existing message is updated (e.g., partial -> complete). + */ + messageUpdated: ClineMessage + + /** + * Emitted when the agent starts waiting for user input. + * Convenience event - you can also use stateChange. + */ + waitingForInput: WaitingForInputEvent + + /** + * Emitted when the agent stops waiting and resumes running. + */ + resumedRunning: void + + /** + * Emitted when the agent starts streaming content. + */ + streamingStarted: void + + /** + * Emitted when streaming ends. + */ + streamingEnded: void + + /** + * Emitted when a task completes (either successfully or with error). + */ + taskCompleted: TaskCompletedEvent + + /** + * Emitted when a task is cleared/cancelled. + */ + taskCleared: void + + /** + * Emitted when the current mode changes. + */ + modeChanged: ModeChangedEvent + + /** + * Emitted on any error during message processing. + */ + error: Error +} + +/** + * Event payload for state changes. + */ +export interface AgentStateChangeEvent { + /** The previous state info */ + previousState: AgentStateInfo + /** The new/current state info */ + currentState: AgentStateInfo + /** Whether this is a significant state transition (state enum changed) */ + isSignificantChange: boolean +} + +/** + * Event payload when agent starts waiting for input. + */ +export interface WaitingForInputEvent { + /** The specific ask type */ + ask: ClineAsk + /** Full state info for context */ + stateInfo: AgentStateInfo + /** The message that triggered this wait */ + message: ClineMessage +} + +/** + * Event payload when a task completes. + */ +export interface TaskCompletedEvent { + /** Whether the task completed successfully */ + success: boolean + /** The final state info */ + stateInfo: AgentStateInfo + /** The completion message if available */ + message?: ClineMessage +} + +/** + * Event payload when mode changes. + */ +export interface ModeChangedEvent { + /** The previous mode (undefined if first mode set) */ + previousMode: string | undefined + /** The new/current mode */ + currentMode: string +} + +// ============================================================================= +// Typed Event Emitter +// ============================================================================= + +/** + * Type-safe event emitter for client events. + * + * Usage: + * ```typescript + * const emitter = new TypedEventEmitter() + * + * // Type-safe subscription + * emitter.on('stateChange', (event) => { + * console.log(event.currentState) // TypeScript knows this is AgentStateChangeEvent + * }) + * + * // Type-safe emission + * emitter.emit('stateChange', { previousState, currentState, isSignificantChange }) + * ``` + */ +export class TypedEventEmitter { + private emitter = new EventEmitter() + + /** + * Subscribe to an event. + * + * @param event - The event name + * @param listener - The callback function + * @returns Function to unsubscribe + */ + on(event: K, listener: (payload: ClientEventMap[K]) => void): () => void { + this.emitter.on(event, listener) + return () => this.emitter.off(event, listener) + } + + /** + * Subscribe to an event, but only once. + * + * @param event - The event name + * @param listener - The callback function + */ + once(event: K, listener: (payload: ClientEventMap[K]) => void): void { + this.emitter.once(event, listener) + } + + /** + * Unsubscribe from an event. + * + * @param event - The event name + * @param listener - The callback function to remove + */ + off(event: K, listener: (payload: ClientEventMap[K]) => void): void { + this.emitter.off(event, listener) + } + + /** + * Emit an event. + * + * @param event - The event name + * @param payload - The event payload + */ + emit(event: K, payload: ClientEventMap[K]): void { + this.emitter.emit(event, payload) + } + + /** + * Remove all listeners for an event, or all events. + * + * @param event - Optional event name. If not provided, removes all listeners. + */ + removeAllListeners(event?: K): void { + if (event) { + this.emitter.removeAllListeners(event) + } else { + this.emitter.removeAllListeners() + } + } + + /** + * Get the number of listeners for an event. + */ + listenerCount(event: K): number { + return this.emitter.listenerCount(event) + } +} + +// ============================================================================= +// State Change Detector +// ============================================================================= + +/** + * Helper to determine if a state change is "significant". + * + * A significant change is when the AgentLoopState enum value changes, + * as opposed to just internal state updates within the same state. + */ +export function isSignificantStateChange(previous: AgentStateInfo, current: AgentStateInfo): boolean { + return previous.state !== current.state +} + +/** + * Helper to determine if we transitioned to waiting for input. + */ +export function transitionedToWaiting(previous: AgentStateInfo, current: AgentStateInfo): boolean { + return !previous.isWaitingForInput && current.isWaitingForInput +} + +/** + * Helper to determine if we transitioned from waiting to running. + */ +export function transitionedToRunning(previous: AgentStateInfo, current: AgentStateInfo): boolean { + return previous.isWaitingForInput && !current.isWaitingForInput && current.isRunning +} + +/** + * Helper to determine if streaming started. + */ +export function streamingStarted(previous: AgentStateInfo, current: AgentStateInfo): boolean { + return !previous.isStreaming && current.isStreaming +} + +/** + * Helper to determine if streaming ended. + */ +export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo): boolean { + return previous.isStreaming && !current.isStreaming +} + +/** + * Helper to determine if task completed. + */ +export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean { + const completionAsks = ["completion_result", "resume_completed_task"] + const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk) + const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk) + return wasNotComplete && isNowComplete +} + +// ============================================================================= +// Observable Pattern (Alternative API) +// ============================================================================= + +/** + * Subscription function type for observable pattern. + */ +export type Observer = (value: T) => void + +/** + * Unsubscribe function type. + */ +export type Unsubscribe = () => void + +/** + * Simple observable for state. + * + * This provides an alternative to the event emitter pattern + * for those who prefer a more functional approach. + * + * Usage: + * ```typescript + * const stateObservable = new Observable() + * + * const unsubscribe = stateObservable.subscribe((state) => { + * console.log('New state:', state) + * }) + * + * // Later... + * unsubscribe() + * ``` + */ +export class Observable { + private observers: Set> = new Set() + private currentValue: T | undefined + + /** + * Create an observable with an optional initial value. + */ + constructor(initialValue?: T) { + this.currentValue = initialValue + } + + /** + * Subscribe to value changes. + * + * @param observer - Function called when value changes + * @returns Unsubscribe function + */ + subscribe(observer: Observer): Unsubscribe { + this.observers.add(observer) + + // Immediately emit current value if we have one + if (this.currentValue !== undefined) { + observer(this.currentValue) + } + + return () => { + this.observers.delete(observer) + } + } + + /** + * Update the value and notify all subscribers. + */ + next(value: T): void { + this.currentValue = value + for (const observer of this.observers) { + try { + observer(value) + } catch (error) { + console.error("Error in observer:", error) + } + } + } + + /** + * Get the current value without subscribing. + */ + getValue(): T | undefined { + return this.currentValue + } + + /** + * Check if there are any subscribers. + */ + hasSubscribers(): boolean { + return this.observers.size > 0 + } + + /** + * Get the number of subscribers. + */ + getSubscriberCount(): number { + return this.observers.size + } + + /** + * Remove all subscribers. + */ + clear(): void { + this.observers.clear() + } +} diff --git a/apps/cli/src/agent/extension-client.ts b/apps/cli/src/agent/extension-client.ts new file mode 100644 index 00000000000..c2d77dfdd91 --- /dev/null +++ b/apps/cli/src/agent/extension-client.ts @@ -0,0 +1,580 @@ +/** + * Roo Code Client + * + * This is the main entry point for the client library. It provides a high-level + * API for: + * - Processing messages from the extension host + * - Querying the current agent state + * - Subscribing to state change events + * - Sending responses back to the extension + * + * The client is designed to be transport-agnostic. You provide a way to send + * messages to the extension, and you feed incoming messages to the client. + * + * Architecture: + * ``` + * ┌───────────────────────────────────────────────┐ + * │ ExtensionClient │ + * │ │ + * Extension ──────▶ │ MessageProcessor ──▶ StateStore │ + * Messages │ │ │ │ + * │ ▼ ▼ │ + * │ TypedEventEmitter ◀── State/Events │ + * │ │ │ + * │ ▼ │ + * │ Your Event Handlers │ + * └───────────────────────────────────────────────┘ + * ``` + */ + +import type { ExtensionMessage, WebviewMessage, ClineAskResponse, ClineMessage, ClineAsk } from "@roo-code/types" + +import { StateStore } from "./state-store.js" +import { MessageProcessor, parseExtensionMessage } from "./message-processor.js" +import { + TypedEventEmitter, + type ClientEventMap, + type AgentStateChangeEvent, + type WaitingForInputEvent, + type ModeChangedEvent, +} from "./events.js" +import { AgentLoopState, type AgentStateInfo } from "./agent-state.js" + +// ============================================================================= +// Extension Client Configuration +// ============================================================================= + +/** + * Configuration options for the ExtensionClient. + */ +export interface ExtensionClientConfig { + /** + * Function to send messages to the extension host. + * This is how the client communicates back to the extension. + * + * Example implementations: + * - VSCode webview: (msg) => vscode.postMessage(msg) + * - WebSocket: (msg) => socket.send(JSON.stringify(msg)) + * - IPC: (msg) => process.send(msg) + */ + sendMessage: (message: WebviewMessage) => void + + /** + * Whether to emit events for all state changes or only significant ones. + * Default: true + */ + emitAllStateChanges?: boolean + + /** + * Enable debug logging. + * Default: false + */ + debug?: boolean + + /** + * Maximum state history size (for debugging). + * Set to 0 to disable history tracking. + * Default: 0 + */ + maxHistorySize?: number +} + +// ============================================================================= +// Main Client Class +// ============================================================================= + +/** + * ExtensionClient is the main interface for interacting with the Roo Code extension. + * + * Basic usage: + * ```typescript + * // Create client with message sender + * const client = new ExtensionClient({ + * sendMessage: (msg) => vscode.postMessage(msg) + * }) + * + * // Subscribe to state changes + * client.on('stateChange', (event) => { + * console.log('State:', event.currentState.state) + * }) + * + * // Subscribe to specific events + * client.on('waitingForInput', (event) => { + * console.log('Waiting for:', event.ask) + * }) + * + * // Feed messages from extension + * window.addEventListener('message', (e) => { + * client.handleMessage(e.data) + * }) + * + * // Query state at any time + * const state = client.getAgentState() + * if (state.isWaitingForInput) { + * // Show approval UI + * } + * + * // Send responses + * client.approve() // or client.reject() or client.respond('answer') + * ``` + */ +export class ExtensionClient { + private store: StateStore + private processor: MessageProcessor + private emitter: TypedEventEmitter + private sendMessage: (message: WebviewMessage) => void + private debug: boolean + + constructor(config: ExtensionClientConfig) { + this.sendMessage = config.sendMessage + this.debug = config.debug ?? false + this.store = new StateStore({ maxHistorySize: config.maxHistorySize ?? 0 }) + this.emitter = new TypedEventEmitter() + + this.processor = new MessageProcessor(this.store, this.emitter, { + emitAllStateChanges: config.emitAllStateChanges ?? true, + debug: config.debug ?? false, + }) + } + + // =========================================================================== + // Message Handling + // =========================================================================== + + /** + * Handle an incoming message from the extension host. + * + * Call this method whenever you receive a message from the extension. + * The client will parse, validate, and process the message, updating + * internal state and emitting appropriate events. + * + * @param message - The raw message (can be ExtensionMessage or JSON string) + */ + handleMessage(message: ExtensionMessage | string): void { + let parsed: ExtensionMessage | undefined + + if (typeof message === "string") { + parsed = parseExtensionMessage(message) + + if (!parsed) { + if (this.debug) { + console.log("[ExtensionClient] Failed to parse message:", message) + } + + return + } + } else { + parsed = message + } + + this.processor.processMessage(parsed) + } + + /** + * Handle multiple messages at once. + */ + handleMessages(messages: (ExtensionMessage | string)[]): void { + for (const message of messages) { + this.handleMessage(message) + } + } + + // =========================================================================== + // State Queries - Always know the current state + // =========================================================================== + + /** + * Get the complete agent state information. + * + * This returns everything you need to know about the current state: + * - The high-level state (running, streaming, waiting, idle, etc.) + * - Whether input is needed + * - The specific ask type if waiting + * - What action is required + * - Human-readable description + */ + getAgentState(): AgentStateInfo { + return this.store.getAgentState() + } + + /** + * Get just the current state enum value. + */ + getCurrentState(): AgentLoopState { + return this.store.getCurrentState() + } + + /** + * Check if the agent is waiting for user input. + */ + isWaitingForInput(): boolean { + return this.store.isWaitingForInput() + } + + /** + * Check if the agent is actively running. + */ + isRunning(): boolean { + return this.store.isRunning() + } + + /** + * Check if content is currently streaming. + */ + isStreaming(): boolean { + return this.store.isStreaming() + } + + /** + * Check if there is an active task. + */ + hasActiveTask(): boolean { + return this.store.getCurrentState() !== AgentLoopState.NO_TASK + } + + /** + * Get all messages in the current task. + */ + getMessages(): ClineMessage[] { + return this.store.getMessages() + } + + /** + * Get the last message. + */ + getLastMessage(): ClineMessage | undefined { + return this.store.getLastMessage() + } + + /** + * Get the current ask type if the agent is waiting for input. + */ + getCurrentAsk(): ClineAsk | undefined { + return this.store.getAgentState().currentAsk + } + + /** + * Check if the client has received any state from the extension. + */ + isInitialized(): boolean { + return this.store.isInitialized() + } + + /** + * Get the current mode (e.g., "code", "architect", "ask"). + * Returns undefined if no mode has been received yet. + */ + getCurrentMode(): string | undefined { + return this.store.getCurrentMode() + } + + // =========================================================================== + // Event Subscriptions - Realtime notifications + // =========================================================================== + + /** + * Subscribe to an event. + * + * Returns an unsubscribe function for easy cleanup. + * + * @param event - The event to subscribe to + * @param listener - The callback function + * @returns Unsubscribe function + * + * @example + * ```typescript + * const unsubscribe = client.on('stateChange', (event) => { + * console.log(event.currentState) + * }) + * + * // Later, to unsubscribe: + * unsubscribe() + * ``` + */ + on(event: K, listener: (payload: ClientEventMap[K]) => void): () => void { + return this.emitter.on(event, listener) + } + + /** + * Subscribe to an event, triggered only once. + */ + once(event: K, listener: (payload: ClientEventMap[K]) => void): void { + this.emitter.once(event, listener) + } + + /** + * Unsubscribe from an event. + */ + off(event: K, listener: (payload: ClientEventMap[K]) => void): void { + this.emitter.off(event, listener) + } + + /** + * Remove all listeners for an event, or all events. + */ + removeAllListeners(event?: K): void { + this.emitter.removeAllListeners(event) + } + + /** + * Convenience method: Subscribe only to state changes. + */ + onStateChange(listener: (event: AgentStateChangeEvent) => void): () => void { + return this.on("stateChange", listener) + } + + /** + * Convenience method: Subscribe only to waiting events. + */ + onWaitingForInput(listener: (event: WaitingForInputEvent) => void): () => void { + return this.on("waitingForInput", listener) + } + + /** + * Convenience method: Subscribe only to mode changes. + */ + onModeChanged(listener: (event: ModeChangedEvent) => void): () => void { + return this.on("modeChanged", listener) + } + + // =========================================================================== + // Response Methods - Send actions to the extension + // =========================================================================== + + /** + * Approve the current action (tool, command, browser, MCP). + * + * Use when the agent is waiting for approval (interactive asks). + */ + approve(): void { + this.sendResponse("yesButtonClicked") + } + + /** + * Reject the current action. + * + * Use when you want to deny a tool, command, or other action. + */ + reject(): void { + this.sendResponse("noButtonClicked") + } + + /** + * Send a text response. + * + * Use for: + * - Answering follow-up questions + * - Providing additional context + * - Giving feedback on completion + * + * @param text - The response text + * @param images - Optional base64-encoded images + */ + respond(text: string, images?: string[]): void { + this.sendResponse("messageResponse", text, images) + } + + /** + * Generic method to send any ask response. + * + * @param response - The response type + * @param text - Optional text content + * @param images - Optional images + */ + sendResponse(response: ClineAskResponse, text?: string, images?: string[]): void { + const message: WebviewMessage = { + type: "askResponse", + askResponse: response, + text, + images, + } + this.sendMessage(message) + } + + // =========================================================================== + // Task Control Methods + // =========================================================================== + + /** + * Start a new task with the given prompt. + * + * @param text - The task description/prompt + * @param images - Optional base64-encoded images + */ + newTask(text: string, images?: string[]): void { + const message: WebviewMessage = { + type: "newTask", + text, + images, + } + this.sendMessage(message) + } + + /** + * Clear the current task. + * + * This ends the current task and resets to a fresh state. + */ + clearTask(): void { + const message: WebviewMessage = { + type: "clearTask", + } + this.sendMessage(message) + this.processor.notifyTaskCleared() + } + + /** + * Cancel a running task. + * + * Use this to interrupt a task that is currently processing. + */ + cancelTask(): void { + const message: WebviewMessage = { + type: "cancelTask", + } + this.sendMessage(message) + } + + /** + * Resume a paused task. + * + * Use when the agent state is RESUMABLE (resume_task ask). + */ + resumeTask(): void { + this.approve() // Resume uses the same response as approve + } + + /** + * Retry a failed API request. + * + * Use when the agent state shows api_req_failed. + */ + retryApiRequest(): void { + this.approve() // Retry uses the same response as approve + } + + // =========================================================================== + // Terminal Operation Methods + // =========================================================================== + + /** + * Continue terminal output (don't wait for more output). + * + * Use when the agent is showing command_output and you want to proceed. + */ + continueTerminal(): void { + const message: WebviewMessage = { + type: "terminalOperation", + terminalOperation: "continue", + } + this.sendMessage(message) + } + + /** + * Abort terminal command. + * + * Use when you want to kill a running terminal command. + */ + abortTerminal(): void { + const message: WebviewMessage = { + type: "terminalOperation", + terminalOperation: "abort", + } + this.sendMessage(message) + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Reset the client state. + * + * This clears all internal state and history. + * Useful when disconnecting or starting fresh. + */ + reset(): void { + this.store.reset() + this.emitter.removeAllListeners() + } + + /** + * Get the state history (if history tracking is enabled). + */ + getStateHistory() { + return this.store.getHistory() + } + + /** + * Enable or disable debug mode. + */ + setDebug(enabled: boolean): void { + this.debug = enabled + this.processor.setDebug(enabled) + } + + // =========================================================================== + // Advanced: Direct Store Access + // =========================================================================== + + /** + * Get direct access to the state store. + * + * This is for advanced use cases where you need more control. + * Most users should use the methods above instead. + */ + getStore(): StateStore { + return this.store + } + + /** + * Get direct access to the event emitter. + */ + getEmitter(): TypedEventEmitter { + return this.emitter + } +} + +// ============================================================================= +// Factory Functions +// ============================================================================= + +/** + * Create a new ExtensionClient instance. + * + * This is a convenience function that creates a client with default settings. + * + * @param sendMessage - Function to send messages to the extension + * @returns A new ExtensionClient instance + */ +export function createClient(sendMessage: (message: WebviewMessage) => void): ExtensionClient { + return new ExtensionClient({ sendMessage }) +} + +/** + * Create a mock client for testing. + * + * The mock client captures all sent messages for verification. + * + * @returns An object with the client and captured messages + */ +export function createMockClient(): { + client: ExtensionClient + sentMessages: WebviewMessage[] + clearMessages: () => void +} { + const sentMessages: WebviewMessage[] = [] + + const client = new ExtensionClient({ + sendMessage: (message) => sentMessages.push(message), + debug: false, + }) + + return { + client, + sentMessages, + clearMessages: () => { + sentMessages.length = 0 + }, + } +} diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts new file mode 100644 index 00000000000..8628371b1a8 --- /dev/null +++ b/apps/cli/src/agent/extension-host.ts @@ -0,0 +1,614 @@ +/** + * ExtensionHost - Loads and runs the Roo Code extension in CLI mode + * + * This class is a thin coordination layer responsible for: + * 1. Creating the vscode-shim mock + * 2. Loading the extension bundle via require() + * 3. Activating the extension + * 4. Wiring up managers for output, prompting, and ask handling + */ + +import { createRequire } from "module" +import path from "path" +import { fileURLToPath } from "url" +import fs from "fs" +import { EventEmitter } from "events" + +import pWaitFor from "p-wait-for" + +import type { + ClineMessage, + ExtensionMessage, + ReasoningEffortExtended, + RooCodeSettings, + WebviewMessage, +} from "@roo-code/types" +import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim" +import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli" + +import { DEFAULT_FLAGS, type SupportedProvider } from "@/types/index.js" +import type { User } from "@/lib/sdk/index.js" +import { getProviderSettings } from "@/lib/utils/provider.js" +import { createEphemeralStorageDir } from "@/lib/storage/index.js" + +import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js" +import type { AgentStateInfo } from "./agent-state.js" +import { ExtensionClient } from "./extension-client.js" +import { OutputManager } from "./output-manager.js" +import { PromptManager } from "./prompt-manager.js" +import { AskDispatcher } from "./ask-dispatcher.js" + +// Pre-configured logger for CLI message activity debugging. +const cliLogger = new DebugLogger("CLI") + +// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) +// When running from a release tarball, ROO_CLI_ROOT is set by the wrapper script. +// In development, we fall back to finding the CLI package root by walking up to package.json. +// This works whether running from dist/ (bundled) or src/agent/ (tsx dev). +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function findCliPackageRoot(): string { + let dir = __dirname + + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, "package.json"))) { + return dir + } + + dir = path.dirname(dir) + } + + return path.resolve(__dirname, "..") +} + +const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot() + +export interface ExtensionHostOptions { + mode: string + reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled" + consecutiveMistakeLimit?: number + user: User | null + provider: SupportedProvider + apiKey?: string + model: string + workspacePath: string + extensionPath: string + nonInteractive?: boolean + /** + * When true, uses a temporary storage directory that is cleaned up on exit. + */ + ephemeral: boolean + debug: boolean + exitOnComplete: boolean + terminalShell?: string + /** + * When true, exit the process on API request errors instead of retrying. + */ + exitOnError?: boolean + /** + * When true, completely disables all direct stdout/stderr output. + * Use this when running in TUI mode where Ink controls the terminal. + */ + disableOutput?: boolean + /** + * When true, don't suppress node warnings and console output since we're + * running in an integration test and we want to see the output. + */ + integrationTest?: boolean +} + +interface ExtensionModule { + activate: (context: unknown) => Promise + deactivate?: () => Promise +} + +interface WebviewViewProvider { + resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise +} + +export interface ExtensionHostInterface extends IExtensionHost { + client: ExtensionClient + activate(): Promise + runTask(prompt: string, taskId?: string, configuration?: RooCodeSettings, images?: string[]): Promise + resumeTask(taskId: string): Promise + sendToExtension(message: WebviewMessage): void + dispose(): Promise +} + +export class ExtensionHost extends EventEmitter implements ExtensionHostInterface { + // Extension lifecycle. + private vscode: ReturnType | null = null + private extensionModule: ExtensionModule | null = null + private extensionAPI: unknown = null + private options: ExtensionHostOptions + private isReady = false + private messageListener: ((message: ExtensionMessage) => void) | null = null + private initialSettings: RooCodeSettings + + // Console suppression. + 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 + + // Ephemeral storage. + private ephemeralStorageDir: string | null = null + private previousCliRuntimeEnv: string | undefined + + // ========================================================================== + // Managers - These do all the heavy lifting + // ========================================================================== + + /** + * ExtensionClient: Single source of truth for agent loop state. + * Handles message processing and state detection. + */ + public readonly client: ExtensionClient + + /** + * OutputManager: Handles all CLI output and streaming. + * Uses Observable pattern internally for stream tracking. + */ + private outputManager: OutputManager + + /** + * PromptManager: Handles all user input collection. + * Provides readline, yes/no, and timed prompts. + */ + private promptManager: PromptManager + + /** + * AskDispatcher: Routes asks to appropriate handlers. + * Uses type guards (isIdleAsk, isInteractiveAsk, etc.) from client module. + */ + private askDispatcher: AskDispatcher + + // ========================================================================== + // Constructor + // ========================================================================== + + constructor(options: ExtensionHostOptions) { + super() + + this.options = options + // Mark this process as CLI runtime so extension code can apply + // CLI-specific behavior without affecting VS Code desktop usage. + this.previousCliRuntimeEnv = process.env.ROO_CLI_RUNTIME + process.env.ROO_CLI_RUNTIME = "1" + + // Enable file-based debug logging only when --debug is passed. + if (options.debug) { + setDebugLogEnabled(true) + } + + // Set up quiet mode early, before any extension code runs. + // This suppresses console output from the extension during load. + this.setupQuietMode() + + // Initialize client - single source of truth for agent state (including mode). + this.client = new ExtensionClient({ + sendMessage: (msg) => this.sendToExtension(msg), + debug: options.debug, // Enable debug logging in the client. + }) + + // Initialize output manager. + this.outputManager = new OutputManager({ disabled: options.disableOutput }) + + // Initialize prompt manager with console mode callbacks. + this.promptManager = new PromptManager({ + onBeforePrompt: () => this.restoreConsole(), + onAfterPrompt: () => this.setupQuietMode(), + }) + + // Initialize ask dispatcher. + this.askDispatcher = new AskDispatcher({ + outputManager: this.outputManager, + promptManager: this.promptManager, + sendMessage: (msg) => this.sendToExtension(msg), + nonInteractive: options.nonInteractive, + exitOnError: options.exitOnError, + disabled: options.disableOutput, // TUI mode handles asks directly. + }) + + // Wire up client events. + this.setupClientEventHandlers() + + // Populate initial settings. + const baseSettings: RooCodeSettings = { + mode: this.options.mode, + consecutiveMistakeLimit: this.options.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit, + commandExecutionTimeout: 300, + enableCheckpoints: false, + experiments: { + customTools: true, + }, + ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model), + } + + this.initialSettings = this.options.nonInteractive + ? { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: true, + alwaysAllowWrite: true, + alwaysAllowWriteOutsideWorkspace: true, + alwaysAllowWriteProtected: true, + alwaysAllowMcp: true, + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + alwaysAllowExecute: true, + allowedCommands: ["*"], + ...baseSettings, + } + : { + autoApprovalEnabled: false, + ...baseSettings, + } + + if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") { + if (this.options.reasoningEffort === "disabled") { + this.initialSettings.enableReasoningEffort = false + } else { + this.initialSettings.enableReasoningEffort = true + this.initialSettings.reasoningEffort = this.options.reasoningEffort + } + } + + if (this.options.terminalShell) { + this.initialSettings.terminalShellIntegrationDisabled = true + this.initialSettings.execaShellPath = this.options.terminalShell + } + } + + // ========================================================================== + // Client Event Handlers + // ========================================================================== + + /** + * Wire up client events to managers. + * The client emits events, managers handle them. + */ + private setupClientEventHandlers(): void { + // Handle new messages - delegate to OutputManager. + this.client.on("message", (msg: ClineMessage) => { + this.logMessageDebug(msg, "new") + this.outputManager.outputMessage(msg) + }) + + // Handle message updates - delegate to OutputManager. + this.client.on("messageUpdated", (msg: ClineMessage) => { + this.logMessageDebug(msg, "updated") + this.outputManager.outputMessage(msg) + }) + + // Handle waiting for input - delegate to AskDispatcher. + this.client.on("waitingForInput", (event: WaitingForInputEvent) => { + this.askDispatcher.handleAsk(event.message) + }) + + // Handle task completion. + this.client.on("taskCompleted", (event: TaskCompletedEvent) => { + // Output completion message via OutputManager. + // Note: completion_result is an "ask" type, not a "say" type. + if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") { + this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "") + } + }) + } + + // ========================================================================== + // Logging + Console Suppression + // ========================================================================== + + private setupQuietMode(): void { + // Skip if already set up or if integrationTest mode + if (this.originalConsole || this.options.integrationTest) { + return + } + + // Suppress node warnings. + this.originalProcessEmitWarning = process.emitWarning + process.emitWarning = () => {} + process.on("warning", () => {}) + + // Suppress console output. + this.originalConsole = { + log: console.log, + warn: console.warn, + error: console.error, + debug: console.debug, + info: console.info, + } + + console.log = () => {} + console.warn = () => {} + console.debug = () => {} + console.info = () => {} + } + + private restoreConsole(): void { + if (!this.originalConsole) { + return + } + + 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 + } + } + + private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void { + if (msg.partial) { + if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) { + this.outputManager.setLoggedFirstPartial(msg.ts) + cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask }) + } + } else { + cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask }) + this.outputManager.clearLoggedFirstPartial(msg.ts) + } + } + + // ========================================================================== + // Extension Lifecycle + // ========================================================================== + + public async activate(): Promise { + const bundlePath = path.join(this.options.extensionPath, "extension.js") + + if (!fs.existsSync(bundlePath)) { + this.restoreConsole() + throw new Error(`Extension bundle not found at: ${bundlePath}`) + } + + let storageDir: string | undefined + + if (this.options.ephemeral) { + this.ephemeralStorageDir = await createEphemeralStorageDir() + storageDir = this.ephemeralStorageDir + } + + // Create VSCode API mock. + this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, { + appRoot: CLI_PACKAGE_ROOT, + storageDir, + }) + ;(global as Record).vscode = this.vscode + ;(global as Record).__extensionHost = this + + // Set up module resolution. + 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) + } + + 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 + + try { + this.extensionModule = require(bundlePath) as ExtensionModule + } catch (error) { + Module._resolveFilename = originalResolve + + throw new Error( + `Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + Module._resolveFilename = originalResolve + + try { + this.extensionAPI = await this.extensionModule.activate(this.vscode.context) + } catch (error) { + throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`) + } + + // Set up message listener - forward all messages to client. + this.messageListener = (message: ExtensionMessage) => this.client.handleMessage(message) + this.on("extensionWebviewMessage", this.messageListener) + + await pWaitFor(() => this.isReady, { interval: 100, timeout: 10_000 }) + } + + public registerWebviewProvider(_viewId: string, _provider: WebviewViewProvider): void {} + + public unregisterWebviewProvider(_viewId: string): void {} + + public markWebviewReady(): void { + this.isReady = true + + // Apply CLI settings to the runtime config and context proxy BEFORE + // sending webviewDidLaunch. This prevents a race condition where the + // webviewDidLaunch handler's first-time init sync reads default state + // instead of the CLI-provided settings. + setRuntimeConfigValues("roo-cline", this.initialSettings as Record) + this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings }) + + // Now trigger extension initialization. The context proxy should already + // have CLI-provided values when the webviewDidLaunch handler runs. + this.sendToExtension({ type: "webviewDidLaunch" }) + } + + public isInInitialSetup(): boolean { + return !this.isReady + } + + // ========================================================================== + // Message Handling + // ========================================================================== + + public sendToExtension(message: WebviewMessage): void { + if (!this.isReady) { + throw new Error("You cannot send messages to the extension before it is ready") + } + + this.emit("webviewMessage", message) + } + + // ========================================================================== + // Task Management + // ========================================================================== + + private waitForTaskCompletion(): Promise { + return new Promise((resolve, reject) => { + const completeHandler = () => { + cleanup() + resolve() + } + + const errorHandler = (error: Error) => { + cleanup() + reject(error) + } + + const cleanup = () => { + this.client.off("taskCompleted", completeHandler) + this.client.off("error", errorHandler) + + if (messageHandler) { + this.client.off("message", messageHandler) + } + } + + // When exitOnError is enabled, listen for api_req_retry_delayed messages + // (sent by Task.ts during auto-approval retry backoff) and exit immediately. + let messageHandler: ((msg: ClineMessage) => void) | null = null + + if (this.options.exitOnError) { + messageHandler = (msg: ClineMessage) => { + if (msg.type === "say" && msg.say === "api_req_retry_delayed") { + cleanup() + reject(new Error(msg.text?.split("\n")[0] || "API request failed")) + } + } + + this.client.on("message", messageHandler) + } + + this.client.once("taskCompleted", completeHandler) + this.client.once("error", errorHandler) + }) + } + + public async runTask( + prompt: string, + taskId?: string, + configuration?: RooCodeSettings, + images?: string[], + ): Promise { + this.sendToExtension({ + type: "newTask", + text: prompt, + taskId, + taskConfiguration: configuration, + ...(images !== undefined ? { images } : {}), + }) + return this.waitForTaskCompletion() + } + + public async resumeTask(taskId: string): Promise { + this.sendToExtension({ type: "showTaskWithId", text: taskId }) + return this.waitForTaskCompletion() + } + + // ========================================================================== + // Public Agent State API + // ========================================================================== + + /** + * Get the current agent loop state. + */ + public getAgentState(): AgentStateInfo { + return this.client.getAgentState() + } + + /** + * Check if the agent is currently waiting for user input. + */ + public isWaitingForInput(): boolean { + return this.client.getAgentState().isWaitingForInput + } + + // ========================================================================== + // Cleanup + // ========================================================================== + + async dispose(): Promise { + // Clear managers. + this.outputManager.clear() + this.askDispatcher.clear() + + // Remove message listener. + if (this.messageListener) { + this.off("extensionWebviewMessage", this.messageListener) + this.messageListener = null + } + + // Reset client. + this.client.reset() + + // Deactivate extension. + if (this.extensionModule?.deactivate) { + try { + await this.extensionModule.deactivate() + } catch { + // NO-OP + } + } + + // Clear references. + this.vscode = null + this.extensionModule = null + this.extensionAPI = null + + // Clear globals. + delete (global as Record).vscode + delete (global as Record).__extensionHost + + // Restore console. + this.restoreConsole() + + // Clean up ephemeral storage. + if (this.ephemeralStorageDir) { + try { + await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true }) + this.ephemeralStorageDir = null + } catch { + // NO-OP + } + } + + // Restore previous CLI runtime marker for process hygiene in tests. + if (this.previousCliRuntimeEnv === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = this.previousCliRuntimeEnv + } + } +} diff --git a/apps/cli/src/agent/index.ts b/apps/cli/src/agent/index.ts new file mode 100644 index 00000000000..7298d506e9d --- /dev/null +++ b/apps/cli/src/agent/index.ts @@ -0,0 +1,2 @@ +export * from "./extension-host.js" +export * from "./json-event-emitter.js" diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts new file mode 100644 index 00000000000..7c60c384bb6 --- /dev/null +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -0,0 +1,905 @@ +/** + * JsonEventEmitter - Handles structured JSON output for the CLI + * + * This class transforms internal CLI events (ClineMessage, state changes, etc.) + * into structured JSON events and outputs them to stdout. + * + * Supports two output modes: + * - "stream-json": NDJSON format (one JSON object per line) for real-time streaming + * - "json": Single JSON object at the end with accumulated events + * + * Schema is optimized for efficiency with high message volume: + * - Minimal fields per event + * - No redundant wrappers + * - `done` flag instead of partial:false + */ + +import type { ClineMessage } from "@roo-code/types" + +import type { JsonEvent, JsonEventCost, JsonEventQueueItem, JsonFinalOutput } from "@/types/json-events.js" + +import type { ExtensionClient } from "./extension-client.js" +import type { AgentStateChangeEvent, TaskCompletedEvent } from "./events.js" +import { AgentLoopState } from "./agent-state.js" + +/** + * Options for JsonEventEmitter. + */ +export interface JsonEventEmitterOptions { + /** Output mode: "json" or "stream-json" */ + mode: "json" | "stream-json" + /** Output stream (defaults to process.stdout) */ + stdout?: NodeJS.WriteStream + /** Optional request id provider for correlating stream events */ + requestIdProvider?: () => string | undefined + /** Transport schema version emitted in system:init */ + schemaVersion?: number + /** Transport protocol identifier emitted in system:init */ + protocol?: string + /** Supported stdin protocol capabilities emitted in system:init */ + capabilities?: string[] +} + +/** + * Parse tool information from a ClineMessage text field. + * Tool messages are JSON with a `tool` field containing the tool name. + */ +function parseToolInfo(text: string | undefined): { name: string; input: Record } | null { + if (!text) return null + try { + const parsed = JSON.parse(text) + return parsed.tool ? { name: parsed.tool, input: parsed } : null + } catch { + return null + } +} + +/** + * Parse API request cost information from api_req_started message text. + */ +function parseApiReqCost(text: string | undefined): JsonEventCost | undefined { + if (!text) return undefined + try { + const parsed = JSON.parse(text) + return parsed.cost !== undefined + ? { + totalCost: parsed.cost, + inputTokens: parsed.tokensIn, + outputTokens: parsed.tokensOut, + cacheWrites: parsed.cacheWrites, + cacheReads: parsed.cacheReads, + } + : undefined + } catch { + return undefined + } +} + +/** Internal events that should not be emitted */ +const SKIP_SAY_TYPES = new Set([ + "api_req_finished", + "api_req_retried", + "api_req_retry_delayed", + "api_req_rate_limit_wait", + "api_req_deleted", + "checkpoint_saved", + "condense_context", + "condense_context_error", + "sliding_window_truncation", +]) + +/** Key offset for reasoning content to avoid collision with text content delta tracking */ +const REASONING_KEY_OFFSET = 1_000_000_000 +/** Grace period to wait for final say:command_output after status:exited */ +const COMMAND_OUTPUT_EXIT_GRACE_MS = 250 + +export class JsonEventEmitter { + private mode: "json" | "stream-json" + private stdout: NodeJS.WriteStream + private events: JsonEvent[] = [] + private unsubscribers: (() => void)[] = [] + private pendingWrites = new Set>() + private lastCost: JsonEventCost | undefined + private requestIdProvider: () => string | undefined + private schemaVersion: number + private protocol: string + private capabilities: string[] + private seenMessageIds = new Set() + // Track previous content for delta computation + private previousContent = new Map() + // Track previous tool-use content for structured (non-append-only) delta computation. + private previousToolUseContent = new Map() + // Track the currently active execute_command tool_use id for command_output correlation. + private activeCommandToolUseId: number | undefined + // Track command output snapshots by command tool-use id for delta computation. + private previousCommandOutputByToolUseId = new Map() + // Track command ids whose output is being streamed from commandExecutionStatus updates. + private statusDrivenCommandOutputIds = new Set() + // Track command ids that already emitted a terminal command_output done event. + private completedCommandOutputIds = new Set() + // Track exited commands awaiting final say:command_output completion. + private pendingCommandCompletionByToolUseId = new Map() + // Track the completion result content + private completionResultContent: string | undefined + // Track the latest assistant text as a fallback for result.content. + private lastAssistantText: string | undefined + // The first non-partial "say:text" per task is the echoed user prompt. + private expectPromptEchoAsUser = true + + constructor(options: JsonEventEmitterOptions) { + this.mode = options.mode + this.stdout = options.stdout ?? process.stdout + this.requestIdProvider = options.requestIdProvider ?? (() => undefined) + this.schemaVersion = options.schemaVersion ?? 1 + this.protocol = options.protocol ?? "roo-cli-stream" + this.capabilities = options.capabilities ?? [ + "stdin:start", + "stdin:message", + "stdin:cancel", + "stdin:ping", + "stdin:shutdown", + ] + } + + /** + * Attach to an ExtensionClient and subscribe to its events. + */ + attachToClient(client: ExtensionClient): void { + // Subscribe to message events + const unsubMessage = client.on("message", (msg) => this.handleMessage(msg, false)) + const unsubMessageUpdated = client.on("messageUpdated", (msg) => this.handleMessage(msg, true)) + const unsubStateChange = client.on("stateChange", (event) => this.handleStateChange(event)) + const unsubTaskCompleted = client.on("taskCompleted", (event) => this.handleTaskCompleted(event)) + const unsubError = client.on("error", (error) => this.handleError(error)) + + this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubStateChange, unsubTaskCompleted, unsubError) + + // Emit init event + this.emitEvent({ + type: "system", + subtype: "init", + content: "Task started", + schemaVersion: this.schemaVersion, + protocol: this.protocol, + capabilities: this.capabilities, + }) + } + + emitControl(event: { + subtype: "ack" | "done" | "error" + requestId?: string + command?: JsonEvent["command"] + taskId?: string + content?: string + success?: boolean + code?: string + }): void { + this.emitEvent({ + type: "control", + subtype: event.subtype, + requestId: event.requestId, + command: event.command, + taskId: event.taskId, + content: event.content, + success: event.success, + code: event.code, + done: event.subtype === "done" ? true : undefined, + }) + } + + emitQueue(event: { + subtype: "snapshot" | "enqueued" | "dequeued" | "drained" | "updated" + taskId?: string + content?: string + queueDepth: number + queue: JsonEventQueueItem[] + }): void { + this.emitEvent({ + type: "queue", + subtype: event.subtype, + taskId: event.taskId, + content: event.content, + queueDepth: event.queueDepth, + queue: event.queue, + }) + } + + private handleStateChange(event: AgentStateChangeEvent): void { + // Only treat the next say:text as a prompt echo when a new task starts. + if ( + event.previousState.state === AgentLoopState.NO_TASK && + event.currentState.state !== AgentLoopState.NO_TASK + ) { + this.expectPromptEchoAsUser = true + } + } + + /** + * Detach from the client and clean up subscriptions. + */ + detach(): void { + for (const unsub of this.unsubscribers) { + unsub() + } + this.unsubscribers = [] + } + + /** + * Compute the delta (new content) for a streaming message. + * Returns null if there's no new content. + */ + private computeDelta(msgId: number, fullContent: string | undefined): string | null { + if (!fullContent) return null + + const previous = this.previousContent.get(msgId) || "" + if (fullContent === previous) return null + + this.previousContent.set(msgId, fullContent) + // If content is appended, return only the new part + return fullContent.startsWith(previous) ? fullContent.slice(previous.length) : fullContent + } + + /** + * Compute a compact delta for structured strings (for tool_use snapshots). + * + * Unlike append-only text streams, tool-use payloads are often full snapshots + * where edits happen before a stable suffix (e.g., inside JSON strings). This + * extracts the inserted segment when possible; otherwise it falls back to the + * full snapshot so consumers can recover. + */ + private computeStructuredDelta(msgId: number, fullContent: string | undefined): string | null { + if (!fullContent) { + return null + } + + const previous = this.previousToolUseContent.get(msgId) || "" + + if (fullContent === previous) { + return null + } + + this.previousToolUseContent.set(msgId, fullContent) + + if (previous.length === 0) { + return fullContent + } + + if (fullContent.startsWith(previous)) { + return fullContent.slice(previous.length) + } + + let prefix = 0 + + while (prefix < previous.length && prefix < fullContent.length && previous[prefix] === fullContent[prefix]) { + prefix++ + } + + let suffix = 0 + + while ( + suffix < previous.length - prefix && + suffix < fullContent.length - prefix && + previous[previous.length - 1 - suffix] === fullContent[fullContent.length - 1 - suffix] + ) { + suffix++ + } + + const isPureInsertion = fullContent.length >= previous.length && prefix + suffix >= previous.length + + if (isPureInsertion) { + return fullContent.slice(prefix, fullContent.length - suffix) + } + + return fullContent + } + + /** + * Check if this is a streaming partial message with no new content. + */ + private isEmptyStreamingDelta(content: string | null): boolean { + return this.mode === "stream-json" && content === null + } + + private computeCommandOutputDelta(commandId: number, fullOutput: string | undefined): string | null { + const normalized = fullOutput ?? "" + const previous = this.previousCommandOutputByToolUseId.get(commandId) || "" + + if (normalized === previous) { + return null + } + + this.previousCommandOutputByToolUseId.set(commandId, normalized) + return normalized.startsWith(previous) ? normalized.slice(previous.length) : normalized + } + + private emitCommandOutputEvent( + commandId: number, + fullOutput: string | undefined, + isDone: boolean, + exitCode?: number, + ): void { + if (this.mode === "stream-json") { + const outputDelta = this.computeCommandOutputDelta(commandId, fullOutput) + const event: JsonEvent = { + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { name: "execute_command" }, + } + + if (outputDelta !== null && outputDelta.length > 0) { + event.tool_result = { name: "execute_command", output: outputDelta } + } + + if (isDone && exitCode !== undefined) { + event.tool_result = { + ...(event.tool_result ?? { name: "execute_command" }), + exitCode, + } + } + + if (isDone) { + event.done = true + this.clearPendingCommandCompletion(commandId) + this.previousCommandOutputByToolUseId.delete(commandId) + this.statusDrivenCommandOutputIds.delete(commandId) + this.completedCommandOutputIds.add(commandId) + if (this.activeCommandToolUseId === commandId) { + this.activeCommandToolUseId = undefined + } + } + + // Suppress empty partial updates that carry no delta. + if (!isDone && outputDelta === null) { + return + } + + this.emitEvent(event) + return + } + + this.emitEvent({ + type: "tool_result", + id: commandId, + subtype: "command", + tool_result: { + name: "execute_command", + output: fullOutput, + ...(isDone && exitCode !== undefined ? { exitCode } : {}), + }, + ...(isDone ? { done: true } : {}), + }) + + if (isDone) { + this.clearPendingCommandCompletion(commandId) + this.previousCommandOutputByToolUseId.delete(commandId) + this.statusDrivenCommandOutputIds.delete(commandId) + this.completedCommandOutputIds.add(commandId) + if (this.activeCommandToolUseId === commandId) { + this.activeCommandToolUseId = undefined + } + } + } + + public emitCommandOutputChunk(outputSnapshot: string): void { + const commandId = this.activeCommandToolUseId + if (commandId === undefined) { + return + } + + this.statusDrivenCommandOutputIds.add(commandId) + this.emitCommandOutputEvent(commandId, outputSnapshot, false) + } + + public markCommandOutputExited(exitCode?: number): void { + const commandId = this.activeCommandToolUseId + if (commandId === undefined) { + return + } + + this.statusDrivenCommandOutputIds.add(commandId) + this.clearPendingCommandCompletion(commandId) + + const timer = setTimeout(() => { + // Fallback close if final say:command_output never arrives. + if (!this.pendingCommandCompletionByToolUseId.has(commandId)) { + return + } + this.pendingCommandCompletionByToolUseId.delete(commandId) + this.emitCommandOutputEvent(commandId, undefined, true, exitCode) + }, COMMAND_OUTPUT_EXIT_GRACE_MS) + timer.unref?.() + + this.pendingCommandCompletionByToolUseId.set(commandId, { exitCode, timer }) + } + + public emitCommandOutputDone(exitCode?: number): void { + const commandId = this.activeCommandToolUseId + if (commandId === undefined) { + return + } + + this.statusDrivenCommandOutputIds.add(commandId) + this.emitCommandOutputEvent(commandId, undefined, true, exitCode) + } + + private clearPendingCommandCompletion(commandId: number): void { + const pending = this.pendingCommandCompletionByToolUseId.get(commandId) + if (!pending) { + return + } + clearTimeout(pending.timer) + this.pendingCommandCompletionByToolUseId.delete(commandId) + } + + /** + * Get content to send for a message (delta for streaming, full for json mode). + */ + private getContentToSend(msgId: number, text: string | undefined, isPartial: boolean): string | null { + if (this.mode === "stream-json" && isPartial) { + return this.computeDelta(msgId, text) + } + + return text ?? null + } + + /** + * Build a base event with optional done flag. + */ + private buildTextEvent( + type: "assistant" | "thinking" | "user", + id: number, + content: string | null, + isDone: boolean, + subtype?: string, + ): JsonEvent { + const event: JsonEvent = { type, id } + + if (content !== null) { + event.content = content + } + + if (subtype) { + event.subtype = subtype + } + + if (isDone) { + event.done = true + } + + return event + } + + /** + * Handle a ClineMessage and emit the appropriate JSON event. + */ + private handleMessage(msg: ClineMessage, _isUpdate: boolean): void { + const isDone = !msg.partial + + // In json mode, only emit complete (non-partial) messages + if (this.mode === "json" && msg.partial) { + return + } + + // Skip duplicate complete messages + if (isDone && this.seenMessageIds.has(msg.ts)) { + return + } + + if (isDone) { + this.seenMessageIds.add(msg.ts) + this.previousContent.delete(msg.ts) + this.previousToolUseContent.delete(msg.ts) + } + + if (msg.type === "say" && msg.say) { + const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false) + + // Skip if no new content for streaming partial messages + if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) { + return + } + + this.handleSayMessage(msg, contentToSend, isDone) + } + + if (msg.type === "ask" && msg.ask) { + this.handleAskMessage(msg, isDone) + } + } + + /** + * Handle "say" type messages. + */ + private handleSayMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void { + switch (msg.say) { + case "text": + if (this.expectPromptEchoAsUser) { + this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone)) + if (isDone) { + this.expectPromptEchoAsUser = false + } + } else { + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone)) + if (msg.text) { + this.lastAssistantText = msg.text + } + } + break + + case "reasoning": + this.handleReasoningMessage(msg, isDone) + break + + case "error": + this.emitEvent({ type: "error", id: msg.ts, content: contentToSend ?? undefined }) + break + + case "command_output": + this.handleCommandOutputMessage(msg, isDone) + break + + case "user_feedback": + case "user_feedback_diff": + this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone)) + if (isDone) { + this.expectPromptEchoAsUser = false + } + break + + case "api_req_started": { + const cost = parseApiReqCost(msg.text) + if (cost) { + this.lastCost = cost + } + break + } + + case "mcp_server_response": + this.emitEvent({ + type: "tool_result", + subtype: "mcp", + tool_result: { name: "mcp_server", output: msg.text }, + }) + break + + case "completion_result": + if (msg.text && !msg.partial) { + this.completionResultContent = msg.text + } + break + + default: + if (SKIP_SAY_TYPES.has(msg.say!)) { + break + } + if (msg.text) { + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.say)) + } + break + } + } + + /** + * Handle reasoning/thinking messages with separate delta tracking. + */ + private handleReasoningMessage(msg: ClineMessage, isDone: boolean): void { + const reasoningContent = msg.reasoning || msg.text + const reasoningKey = msg.ts + REASONING_KEY_OFFSET + const reasoningDelta = this.getContentToSend(reasoningKey, reasoningContent, msg.partial ?? false) + + if (msg.partial && this.isEmptyStreamingDelta(reasoningDelta)) { + return + } + + if (!msg.partial) { + this.previousContent.delete(reasoningKey) + } + + this.emitEvent(this.buildTextEvent("thinking", msg.ts, reasoningDelta, isDone)) + } + + /** + * Handle "ask" type messages. + */ + private handleAskMessage(msg: ClineMessage, isDone: boolean): void { + switch (msg.ask) { + case "tool": + this.handleToolUseAsk(msg, "tool", isDone) + break + + case "command": + this.handleToolUseAsk(msg, "command", isDone) + break + + case "use_mcp_server": + this.handleToolUseAsk(msg, "mcp", isDone) + break + + case "followup": { + const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false) + + // Skip if no new content for streaming partial messages + if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) { + return + } + + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, "followup")) + break + } + + case "command_output": + // Handled in say type + break + + case "completion_result": + if (msg.text && !msg.partial) { + this.completionResultContent = msg.text + } + break + + default: + if (msg.text) { + const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false) + + // Skip if no new content for streaming partial messages + if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) { + return + } + + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.ask)) + } + break + } + } + + private handleToolUseAsk(msg: ClineMessage, subtype: "tool" | "command" | "mcp", isDone: boolean): void { + const isStreamingPartial = this.mode === "stream-json" && msg.partial === true + const toolInfo = parseToolInfo(msg.text) + + if (subtype === "command") { + if (this.activeCommandToolUseId !== undefined && this.activeCommandToolUseId !== msg.ts) { + const previousCommandId = this.activeCommandToolUseId + const pending = this.pendingCommandCompletionByToolUseId.get(previousCommandId) + if (pending) { + clearTimeout(pending.timer) + this.pendingCommandCompletionByToolUseId.delete(previousCommandId) + this.emitCommandOutputEvent(previousCommandId, undefined, true, pending.exitCode) + } + } + + this.activeCommandToolUseId = msg.ts + this.completedCommandOutputIds.delete(msg.ts) + this.clearPendingCommandCompletion(msg.ts) + + if (isStreamingPartial) { + const commandDelta = this.computeStructuredDelta(msg.ts, msg.text) + if (commandDelta === null) { + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "command", + content: commandDelta, + tool_use: { name: "execute_command", input: { command: commandDelta } }, + }) + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "command", + tool_use: { name: "execute_command", input: { command: msg.text } }, + ...(isDone ? { done: true } : {}), + }) + return + } + + if (subtype === "mcp") { + if (isStreamingPartial) { + const mcpDelta = this.computeStructuredDelta(msg.ts, msg.text) + if (mcpDelta === null) { + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "mcp", + content: mcpDelta, + tool_use: { name: "mcp_server" }, + }) + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "mcp", + tool_use: { name: "mcp_server", input: { raw: msg.text } }, + ...(isDone ? { done: true } : {}), + }) + return + } + + if (isStreamingPartial) { + const toolDelta = this.computeStructuredDelta(msg.ts, msg.text) + if (toolDelta === null) { + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "tool", + content: toolDelta, + tool_use: { name: toolInfo?.name ?? "unknown_tool" }, + }) + return + } + + this.emitEvent({ + type: "tool_use", + id: msg.ts, + subtype: "tool", + tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } }, + ...(isDone ? { done: true } : {}), + }) + } + + private handleCommandOutputMessage(msg: ClineMessage, isDone: boolean): void { + const commandId = this.activeCommandToolUseId ?? msg.ts + if (this.completedCommandOutputIds.has(commandId)) { + return + } + + const pending = this.pendingCommandCompletionByToolUseId.get(commandId) + if (pending) { + if (!isDone) { + return + } + clearTimeout(pending.timer) + this.pendingCommandCompletionByToolUseId.delete(commandId) + this.emitCommandOutputEvent(commandId, msg.text, true, pending.exitCode) + return + } + + if (this.statusDrivenCommandOutputIds.has(commandId)) { + return + } + + this.emitCommandOutputEvent(commandId, msg.text, isDone) + } + + /** + * Handle task completion and emit result event. + */ + private handleTaskCompleted(event: TaskCompletedEvent): void { + // Prefer the completion payload from the current event. If it is empty, + // fall back to the most recent tracked completion text, then assistant text. + const resultContent = event.message?.text || this.completionResultContent || this.lastAssistantText + + this.emitEvent({ + type: "result", + id: event.message?.ts ?? Date.now(), + content: resultContent, + done: true, + success: event.success, + cost: this.lastCost, + }) + + // Prevent stale completion content from leaking into later turns. + this.completionResultContent = undefined + this.lastAssistantText = undefined + + // For "json" mode, output the final accumulated result + if (this.mode === "json") { + this.outputFinalResult(event.success, resultContent) + } + } + + /** + * Handle errors and emit error event. + */ + private handleError(error: Error): void { + this.emitEvent({ + type: "error", + id: Date.now(), + content: error.message, + }) + } + + /** + * Emit a JSON event. + * For stream-json mode: immediately output to stdout + * For json mode: accumulate for final output + */ + private emitEvent(event: JsonEvent): void { + const requestId = event.requestId ?? this.requestIdProvider() + const payload = requestId ? { ...event, requestId } : event + + this.events.push(payload) + + if (this.mode === "stream-json") { + this.outputLine(payload) + } + } + + /** + * Output a single JSON line (NDJSON format). + */ + private outputLine(data: unknown): void { + this.writeToStdout(JSON.stringify(data) + "\n") + } + + /** + * Output the final accumulated result (for "json" mode). + */ + private outputFinalResult(success: boolean, content?: string): void { + const output: JsonFinalOutput = { + type: "result", + success, + content, + cost: this.lastCost, + events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself + } + + this.writeToStdout(JSON.stringify(output, null, 2) + "\n") + } + + private writeToStdout(content: string): void { + const writePromise = new Promise((resolve, reject) => { + this.stdout.write(content, (error?: Error | null) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + + this.pendingWrites.add(writePromise) + + void writePromise.finally(() => { + this.pendingWrites.delete(writePromise) + }) + } + + async flush(): Promise { + while (this.pendingWrites.size > 0) { + await Promise.all([...this.pendingWrites]) + } + } + + /** + * Get accumulated events (for testing or external use). + */ + getEvents(): JsonEvent[] { + return [...this.events] + } + + /** + * Clear accumulated events and state. + */ + clear(): void { + this.events = [] + this.lastCost = undefined + this.seenMessageIds.clear() + this.previousContent.clear() + this.previousToolUseContent.clear() + this.activeCommandToolUseId = undefined + this.previousCommandOutputByToolUseId.clear() + this.statusDrivenCommandOutputIds.clear() + this.completedCommandOutputIds.clear() + for (const pending of this.pendingCommandCompletionByToolUseId.values()) { + clearTimeout(pending.timer) + } + this.pendingCommandCompletionByToolUseId.clear() + this.completionResultContent = undefined + this.lastAssistantText = undefined + this.expectPromptEchoAsUser = true + } +} diff --git a/apps/cli/src/agent/message-processor.ts b/apps/cli/src/agent/message-processor.ts new file mode 100644 index 00000000000..f841932dcf6 --- /dev/null +++ b/apps/cli/src/agent/message-processor.ts @@ -0,0 +1,482 @@ +/** + * Message Processor + * + * This module handles incoming messages from the extension host and dispatches + * appropriate state updates and events. It acts as the bridge between raw + * extension messages and the client's internal state management. + * + * Message Flow: + * ``` + * Extension Host ──▶ MessageProcessor ──▶ StateStore ──▶ Events + * ``` + * + * The processor handles different message types: + * - "state": Full state update from extension + * - "messageUpdated": Single message update + * - "action": UI action triggers + * - "invoke": Command invocations + */ + +import { ExtensionMessage, ClineMessage } from "@roo-code/types" +import { debugLog } from "@roo-code/core/cli" + +import type { StateStore } from "./state-store.js" +import type { TypedEventEmitter, AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js" +import { + isSignificantStateChange, + transitionedToWaiting, + transitionedToRunning, + streamingStarted, + streamingEnded, + taskCompleted, +} from "./events.js" +import type { AgentStateInfo } from "./agent-state.js" + +// ============================================================================= +// Message Processor Options +// ============================================================================= + +export interface MessageProcessorOptions { + /** + * Whether to emit events for every state change, or only significant ones. + * Default: true (emit all changes) + */ + emitAllStateChanges?: boolean + + /** + * Whether to log debug information. + * Default: false + */ + debug?: boolean +} + +// ============================================================================= +// Message Processor Class +// ============================================================================= + +/** + * MessageProcessor handles incoming extension messages and updates state accordingly. + * + * It is responsible for: + * 1. Parsing and validating incoming messages + * 2. Updating the state store + * 3. Emitting appropriate events + * + * Usage: + * ```typescript + * const store = new StateStore() + * const emitter = new TypedEventEmitter() + * const processor = new MessageProcessor(store, emitter) + * + * // Process a message from the extension + * processor.processMessage(extensionMessage) + * ``` + */ +export class MessageProcessor { + private store: StateStore + private emitter: TypedEventEmitter + private options: Required + + constructor(store: StateStore, emitter: TypedEventEmitter, options: MessageProcessorOptions = {}) { + this.store = store + this.emitter = emitter + this.options = { + emitAllStateChanges: options.emitAllStateChanges ?? true, + debug: options.debug ?? false, + } + } + + // =========================================================================== + // Main Processing Methods + // =========================================================================== + + /** + * Process an incoming message from the extension host. + * + * This is the main entry point for all extension messages. + * It routes messages to the appropriate handler based on type. + * + * @param message - The raw message from the extension + */ + processMessage(message: ExtensionMessage): void { + if (this.options.debug) { + debugLog("[MessageProcessor] Received message", { type: message.type }) + } + + try { + switch (message.type) { + case "state": + this.handleStateMessage(message) + break + + case "messageUpdated": + this.handleMessageUpdated(message) + break + + case "action": + this.handleAction(message) + break + + case "invoke": + this.handleInvoke(message) + break + + default: + // Other message types are not relevant to state detection + if (this.options.debug) { + debugLog("[MessageProcessor] Ignoring message", { type: message.type }) + } + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + debugLog("[MessageProcessor] Error processing message", { error: err.message }) + this.emitter.emit("error", err) + } + } + + /** + * Process an array of messages (for batch updates). + */ + processMessages(messages: ExtensionMessage[]): void { + for (const message of messages) { + this.processMessage(message) + } + } + + // =========================================================================== + // Message Type Handlers + // =========================================================================== + + /** + * Handle a "state" message - full state update from extension. + * + * This is the most important message type for state detection. + * It contains the complete clineMessages array which is the source of truth. + */ + private handleStateMessage(message: ExtensionMessage): void { + if (!message.state) { + if (this.options.debug) { + debugLog("[MessageProcessor] State message missing state payload") + } + return + } + + const { clineMessages, mode } = message.state + + // Track mode changes. + if (mode && typeof mode === "string") { + const previousMode = this.store.getCurrentMode() + + if (previousMode !== mode) { + if (this.options.debug) { + debugLog("[MessageProcessor] Mode changed", { from: previousMode, to: mode }) + } + + this.store.setCurrentMode(mode) + this.emitter.emit("modeChanged", { previousMode, currentMode: mode }) + } + } + + if (!clineMessages) { + if (this.options.debug) { + debugLog("[MessageProcessor] State message missing clineMessages") + } + return + } + + // Get previous state for comparison. + const previousState = this.store.getAgentState() + + // Update the store with new messages + // Note: We only call setMessages, NOT setExtensionState, to avoid + // double processing (setExtensionState would call setMessages again) + this.store.setMessages(clineMessages) + + // Get new state after update + const currentState = this.store.getAgentState() + + // Debug logging for state message + if (this.options.debug) { + const lastMsg = clineMessages[clineMessages.length - 1] + const lastMsgInfo = lastMsg + ? { + msgType: lastMsg.type === "ask" ? `ask:${lastMsg.ask}` : `say:${lastMsg.say}`, + partial: lastMsg.partial, + textPreview: lastMsg.text?.substring(0, 50), + } + : null + debugLog("[MessageProcessor] State update", { + messageCount: clineMessages.length, + lastMessage: lastMsgInfo, + stateTransition: `${previousState.state} → ${currentState.state}`, + currentAsk: currentState.currentAsk, + isWaitingForInput: currentState.isWaitingForInput, + isStreaming: currentState.isStreaming, + isRunning: currentState.isRunning, + }) + } + + // Emit events based on state changes + this.emitStateChangeEvents(previousState, currentState) + + // Emit new message events for any messages we haven't seen + this.emitNewMessageEvents(previousState, currentState, clineMessages) + } + + /** + * Handle a "messageUpdated" message - single message update. + * + * This is sent when a message is modified (e.g., partial -> complete). + */ + private handleMessageUpdated(message: ExtensionMessage): void { + if (!message.clineMessage) { + if (this.options.debug) { + debugLog("[MessageProcessor] messageUpdated missing clineMessage") + } + return + } + + const clineMessage = message.clineMessage + const previousState = this.store.getAgentState() + + // Update the message in the store + this.store.updateMessage(clineMessage) + + const currentState = this.store.getAgentState() + + // Emit message updated event + this.emitter.emit("messageUpdated", clineMessage) + + // Emit state change events + this.emitStateChangeEvents(previousState, currentState) + } + + /** + * Handle an "action" message - UI action trigger. + * + * These are typically used to trigger UI behaviors and don't + * directly affect agent state, but we can track them if needed. + */ + private handleAction(message: ExtensionMessage): void { + if (this.options.debug) { + debugLog("[MessageProcessor] Action", { action: message.action }) + } + // Actions don't affect agent state, but subclasses could override this + } + + /** + * Handle an "invoke" message - command invocation. + * + * These are commands that should trigger specific behaviors. + */ + private handleInvoke(message: ExtensionMessage): void { + if (this.options.debug) { + debugLog("[MessageProcessor] Invoke", { invoke: message.invoke }) + } + // Invokes don't directly affect state detection + // But they might trigger state changes through subsequent messages + } + + // =========================================================================== + // Event Emission Helpers + // =========================================================================== + + /** + * Emit events based on state changes. + */ + private emitStateChangeEvents(previousState: AgentStateInfo, currentState: AgentStateInfo): void { + const isSignificant = isSignificantStateChange(previousState, currentState) + + // Emit stateChange event + if (this.options.emitAllStateChanges || isSignificant) { + const changeEvent: AgentStateChangeEvent = { + previousState, + currentState, + isSignificantChange: isSignificant, + } + this.emitter.emit("stateChange", changeEvent) + } + + // Emit specific transition events + + // Waiting for input + if (transitionedToWaiting(previousState, currentState)) { + if (currentState.currentAsk && currentState.lastMessage) { + if (this.options.debug) { + debugLog("[MessageProcessor] EMIT waitingForInput", { + ask: currentState.currentAsk, + action: currentState.requiredAction, + }) + } + const waitingEvent: WaitingForInputEvent = { + ask: currentState.currentAsk, + stateInfo: currentState, + message: currentState.lastMessage, + } + this.emitter.emit("waitingForInput", waitingEvent) + } + } + + // Resumed running + if (transitionedToRunning(previousState, currentState)) { + if (this.options.debug) { + debugLog("[MessageProcessor] EMIT resumedRunning") + } + this.emitter.emit("resumedRunning", undefined as void) + } + + // Streaming started + if (streamingStarted(previousState, currentState)) { + if (this.options.debug) { + debugLog("[MessageProcessor] EMIT streamingStarted") + } + this.emitter.emit("streamingStarted", undefined as void) + } + + // Streaming ended + if (streamingEnded(previousState, currentState)) { + if (this.options.debug) { + debugLog("[MessageProcessor] EMIT streamingEnded") + } + this.emitter.emit("streamingEnded", undefined as void) + } + + // Task completed + if (taskCompleted(previousState, currentState)) { + const completedSuccessfully = + currentState.currentAsk === "completion_result" || currentState.currentAsk === "resume_completed_task" + + if (this.options.debug) { + debugLog("[MessageProcessor] EMIT taskCompleted", { + success: completedSuccessfully, + }) + } + const completedEvent: TaskCompletedEvent = { + success: completedSuccessfully, + stateInfo: currentState, + message: currentState.lastMessage, + } + this.emitter.emit("taskCompleted", completedEvent) + } + } + + /** + * Emit events for new messages. + * + * We compare the previous and current message counts to find new messages. + * This is a simple heuristic - for more accuracy, we'd track by timestamp. + */ + private emitNewMessageEvents( + _previousState: AgentStateInfo, + _currentState: AgentStateInfo, + messages: ClineMessage[], + ): void { + // For now, just emit the last message as new + // A more sophisticated implementation would track seen message timestamps + const lastMessage = messages[messages.length - 1] + if (lastMessage) { + this.emitter.emit("message", lastMessage) + } + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Manually trigger a task cleared event. + * Call this when you send a clearTask message to the extension. + */ + notifyTaskCleared(): void { + this.store.clear() + this.emitter.emit("taskCleared", undefined as void) + } + + /** + * Enable or disable debug logging. + */ + setDebug(enabled: boolean): void { + this.options.debug = enabled + } +} + +// ============================================================================= +// Message Validation Helpers +// ============================================================================= + +/** + * Check if a message is a valid ClineMessage. + * Useful for validating messages before processing. + */ +export function isValidClineMessage(message: unknown): message is ClineMessage { + if (!message || typeof message !== "object") { + return false + } + + const msg = message as Record + + // Required fields + if (typeof msg.ts !== "number") { + return false + } + + if (msg.type !== "ask" && msg.type !== "say") { + return false + } + + return true +} + +/** + * Check if a message is a valid ExtensionMessage. + */ +export function isValidExtensionMessage(message: unknown): message is ExtensionMessage { + if (!message || typeof message !== "object") { + return false + } + + const msg = message as Record + + // Must have a type + if (typeof msg.type !== "string") { + return false + } + + return true +} + +// ============================================================================= +// Message Parsing Utilities +// ============================================================================= + +/** + * Parse a JSON string into an ExtensionMessage. + * Returns undefined if parsing fails. + */ +export function parseExtensionMessage(json: string): ExtensionMessage | undefined { + try { + const parsed = JSON.parse(json) + if (isValidExtensionMessage(parsed)) { + return parsed + } + return undefined + } catch { + return undefined + } +} + +/** + * Parse the text field of an api_req_started message. + * Returns undefined if parsing fails or text is not present. + */ +export function parseApiReqStartedText(message: ClineMessage): { cost?: number } | undefined { + if (message.say !== "api_req_started" || !message.text) { + return undefined + } + + try { + return JSON.parse(message.text) + } catch { + return undefined + } +} diff --git a/apps/cli/src/agent/output-manager.ts b/apps/cli/src/agent/output-manager.ts new file mode 100644 index 00000000000..805b0909252 --- /dev/null +++ b/apps/cli/src/agent/output-manager.ts @@ -0,0 +1,463 @@ +/** + * OutputManager - Handles all CLI output and streaming + * + * This manager is responsible for: + * - Writing messages to stdout/stderr + * - Tracking what's been displayed (to avoid duplicates) + * - Managing streaming content with delta computation + * - Formatting different message types appropriately + * + * Design notes: + * - Uses the Observable pattern from client/events.ts for internal state + * - Single responsibility: CLI output only (no prompting, no state detection) + * - Can be disabled for TUI mode where Ink controls the terminal + */ + +import { ClineMessage, ClineSay } from "@roo-code/types" + +import { Observable } from "./events.js" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Tracks what we've displayed for a specific message ts. + */ +export interface DisplayedMessage { + ts: number + text: string + partial: boolean +} + +/** + * Tracks streaming state for a message. + */ +export interface StreamState { + ts: number + text: string + headerShown: boolean +} + +/** + * Configuration options for OutputManager. + */ +export interface OutputManagerOptions { + /** + * When true, completely disables all output. + * Use for TUI mode where another system controls the terminal. + */ + disabled?: boolean + + /** + * Stream for normal output (default: process.stdout). + */ + stdout?: NodeJS.WriteStream + + /** + * Stream for error output (default: process.stderr). + */ + stderr?: NodeJS.WriteStream +} + +// ============================================================================= +// OutputManager Class +// ============================================================================= + +export class OutputManager { + private disabled: boolean + private stdout: NodeJS.WriteStream + private stderr: NodeJS.WriteStream + + /** + * Track displayed messages by ts to avoid duplicate output. + * Observable pattern allows external systems to subscribe if needed. + */ + private displayedMessages = new Map() + + /** + * Track streamed content by ts for delta computation. + */ + private streamedContent = new Map() + + /** + * Track which ts is currently streaming (for newline management). + */ + private currentlyStreamingTs: number | null = null + + /** + * Track whether a say:completion_result has been streamed, + * so the subsequent ask:completion_result doesn't duplicate the text. + */ + private completionResultStreamed = false + + /** + * Track first partial logs (for debugging first/last pattern). + */ + private loggedFirstPartial = new Set() + + /** + * Observable for streaming state changes. + * External systems can subscribe to know when streaming starts/ends. + */ + public readonly streamingState = new Observable<{ ts: number | null; isStreaming: boolean }>({ + ts: null, + isStreaming: false, + }) + + constructor(options: OutputManagerOptions = {}) { + this.disabled = options.disabled ?? false + this.stdout = options.stdout ?? process.stdout + this.stderr = options.stderr ?? process.stderr + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Output a ClineMessage based on its type. + * This is the main entry point for message output. + * + * @param msg - The message to output + * @param skipFirstUserMessage - If true, skip the first "text" message (user prompt echo) + */ + outputMessage(msg: ClineMessage, skipFirstUserMessage = true): void { + const ts = msg.ts + const text = msg.text || "" + const isPartial = msg.partial === true + const previousDisplay = this.displayedMessages.get(ts) + const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial + + if (msg.type === "say" && msg.say) { + this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage) + } else if (msg.type === "ask" && msg.ask) { + // For ask messages, we only output command_output here + // Other asks are handled by AskDispatcher + if (msg.ask === "command_output") { + this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete) + } + } + } + + /** + * Output a simple text line with a label. + */ + output(label: string, text?: string): void { + if (this.disabled) return + const message = text ? `${label} ${text}\n` : `${label}\n` + this.stdout.write(message) + } + + /** + * Output an error message. + */ + outputError(label: string, text?: string): void { + if (this.disabled) return + const message = text ? `${label} ${text}\n` : `${label}\n` + this.stderr.write(message) + } + + /** + * Write raw text to stdout (for streaming). + */ + writeRaw(text: string): void { + if (this.disabled) return + this.stdout.write(text) + } + + /** + * Check if a message has already been fully displayed. + */ + isAlreadyDisplayed(ts: number): boolean { + const displayed = this.displayedMessages.get(ts) + return displayed !== undefined && !displayed.partial + } + + /** + * Check if we're currently streaming any message. + */ + isCurrentlyStreaming(): boolean { + return this.currentlyStreamingTs !== null + } + + /** + * Get the ts of the currently streaming message. + */ + getCurrentlyStreamingTs(): number | null { + return this.currentlyStreamingTs + } + + /** + * Mark a message as displayed (useful for external coordination). + */ + markDisplayed(ts: number, text: string, partial: boolean): void { + this.displayedMessages.set(ts, { ts, text, partial }) + } + + /** + * Clear all tracking state. + * Call this when starting a new task. + */ + clear(): void { + this.displayedMessages.clear() + this.streamedContent.clear() + this.currentlyStreamingTs = null + this.completionResultStreamed = false + this.loggedFirstPartial.clear() + this.streamingState.next({ ts: null, isStreaming: false }) + } + + /** + * Get debugging info about first partial logging. + */ + hasLoggedFirstPartial(ts: number): boolean { + return this.loggedFirstPartial.has(ts) + } + + /** + * Record that we've logged the first partial for a ts. + */ + setLoggedFirstPartial(ts: number): void { + this.loggedFirstPartial.add(ts) + } + + /** + * Clear the first partial record (when complete). + */ + clearLoggedFirstPartial(ts: number): void { + this.loggedFirstPartial.delete(ts) + } + + // =========================================================================== + // Say Message Output + // =========================================================================== + + private outputSayMessage( + ts: number, + say: ClineSay, + text: string, + isPartial: boolean, + alreadyDisplayedComplete: boolean | undefined, + skipFirstUserMessage: boolean, + ): void { + switch (say) { + case "text": + this.outputTextMessage(ts, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage) + break + + // case "thinking": - not a valid ClineSay type + case "reasoning": + this.outputReasoningMessage(ts, text, isPartial, alreadyDisplayedComplete) + break + + case "command_output": + this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete) + break + + case "completion_result": + // completion_result can arrive as both a "say" (with streamed text) + // and an "ask" (handled via TaskCompleted in extension-host.ts). + // Stream the say variant here; the ask variant is handled by + // outputCompletionResult which will skip if already displayed. + this.outputCompletionSayMessage(ts, text, isPartial, alreadyDisplayedComplete) + break + + case "error": + if (!alreadyDisplayedComplete) { + this.outputError("\n[error]", text || "Unknown error") + this.displayedMessages.set(ts, { ts, text: text || "", partial: false }) + } + break + + case "api_req_started": + // Silent - no output needed + break + + default: + // NO-OP for unknown say types + break + } + } + + private outputTextMessage( + ts: number, + text: string, + isPartial: boolean, + alreadyDisplayedComplete: boolean | undefined, + skipFirstUserMessage: boolean, + ): void { + // Skip the initial user prompt echo (first message with no prior messages) + if (skipFirstUserMessage && this.displayedMessages.size === 0 && !this.displayedMessages.has(ts)) { + this.displayedMessages.set(ts, { ts, text, partial: !!isPartial }) + return + } + + if (isPartial && text) { + // Stream partial content + this.streamContent(ts, text, "[assistant]") + this.displayedMessages.set(ts, { 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.writeRaw(delta) + } + this.finishStream(ts) + } else { + // Not streamed yet - output complete message + this.output("\n[assistant]", text) + } + + this.displayedMessages.set(ts, { ts, text, partial: false }) + this.streamedContent.set(ts, { ts, text, headerShown: true }) + } + } + + private outputReasoningMessage( + ts: number, + text: string, + isPartial: boolean, + alreadyDisplayedComplete: boolean | undefined, + ): void { + if (isPartial && text) { + this.streamContent(ts, text, "[reasoning]") + this.displayedMessages.set(ts, { 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.writeRaw(delta) + } + this.finishStream(ts) + } else { + this.output("\n[reasoning]", text) + } + + this.displayedMessages.set(ts, { ts, text, partial: false }) + } + } + + /** + * Output command_output (shared between say and ask types). + */ + outputCommandOutput( + ts: number, + text: string, + isPartial: boolean, + alreadyDisplayedComplete: boolean | undefined, + ): void { + if (isPartial && text) { + this.streamContent(ts, text, "[command output]") + this.displayedMessages.set(ts, { ts, text, partial: true }) + } else if (!isPartial && text && !alreadyDisplayedComplete) { + 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.writeRaw(delta) + } + this.finishStream(ts) + } else { + this.writeRaw("\n[command output] ") + this.writeRaw(text) + this.writeRaw("\n") + } + + this.displayedMessages.set(ts, { ts, text, partial: false }) + this.streamedContent.set(ts, { ts, text, headerShown: true }) + } + } + + // =========================================================================== + // Streaming Helpers + // =========================================================================== + + /** + * Stream content with delta computation - only output new characters. + */ + 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.writeRaw(`\n${header} `) + this.writeRaw(text) + this.streamedContent.set(ts, { ts, text, headerShown: true }) + this.currentlyStreamingTs = ts + this.streamingState.next({ ts, isStreaming: true }) + } else if (text.length > previous.text.length && text.startsWith(previous.text)) { + // Text has grown - output delta + const delta = text.slice(previous.text.length) + this.writeRaw(delta) + this.streamedContent.set(ts, { ts, text, headerShown: true }) + } + } + + /** + * Finish streaming a message (add newline). + */ + finishStream(ts: number): void { + if (this.currentlyStreamingTs === ts) { + this.writeRaw("\n") + this.currentlyStreamingTs = null + this.streamingState.next({ ts: null, isStreaming: false }) + } + } + + /** + * Output a say:completion_result message (streamed text of the completion). + * The subsequent ask:completion_result is handled by outputCompletionResult. + */ + private outputCompletionSayMessage( + ts: number, + text: string, + isPartial: boolean, + alreadyDisplayedComplete: boolean | undefined, + ): void { + if (isPartial && text) { + this.streamContent(ts, text, "[assistant]") + this.displayedMessages.set(ts, { ts, text, partial: true }) + this.completionResultStreamed = true + } else if (!isPartial && text && !alreadyDisplayedComplete) { + 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.writeRaw(delta) + } + this.finishStream(ts) + } else { + this.output("\n[assistant]", text) + } + + this.displayedMessages.set(ts, { ts, text, partial: false }) + this.completionResultStreamed = true + } + } + + /** + * Output completion message (called from TaskCompleted handler). + */ + outputCompletionResult(ts: number, text: string): void { + const previousDisplay = this.displayedMessages.get(ts) + if (!previousDisplay || previousDisplay.partial) { + if (this.completionResultStreamed) { + // Text was already streamed via say:completion_result. + this.output("\n[task complete]") + } else { + this.output("\n[task complete]", text || "") + } + this.displayedMessages.set(ts, { ts, text: text || "", partial: false }) + } + } +} diff --git a/apps/cli/src/agent/prompt-manager.ts b/apps/cli/src/agent/prompt-manager.ts new file mode 100644 index 00000000000..40f1c38d586 --- /dev/null +++ b/apps/cli/src/agent/prompt-manager.ts @@ -0,0 +1,297 @@ +/** + * PromptManager - Handles all user input collection + * + * This manager is responsible for: + * - Collecting user input via readline + * - Yes/No prompts with proper defaults + * - Timed prompts that auto-select after timeout + * - Raw mode input for character-by-character handling + * + * Design notes: + * - Single responsibility: User input only (no output formatting) + * - Returns Promises for all input operations + * - Handles console mode switching (quiet mode restore) + * - Can be disabled for programmatic (non-interactive) use + */ + +import readline from "readline" + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Configuration options for PromptManager. + */ +export interface PromptManagerOptions { + /** + * Called before prompting to restore console output. + * Used to exit quiet mode temporarily. + */ + onBeforePrompt?: () => void + + /** + * Called after prompting to re-enable quiet mode. + */ + onAfterPrompt?: () => void + + /** + * Stream for input (default: process.stdin). + */ + stdin?: NodeJS.ReadStream + + /** + * Stream for prompt output (default: process.stdout). + */ + stdout?: NodeJS.WriteStream +} + +/** + * Result of a timed prompt. + */ +export interface TimedPromptResult { + /** The user's input, or default if timed out */ + value: string + /** Whether the result came from timeout */ + timedOut: boolean + /** Whether the user cancelled (Ctrl+C) */ + cancelled: boolean +} + +// ============================================================================= +// PromptManager Class +// ============================================================================= + +export class PromptManager { + private onBeforePrompt?: () => void + private onAfterPrompt?: () => void + private stdin: NodeJS.ReadStream + private stdout: NodeJS.WriteStream + + /** + * Track if a prompt is currently active. + */ + private isPrompting = false + + constructor(options: PromptManagerOptions = {}) { + this.onBeforePrompt = options.onBeforePrompt + this.onAfterPrompt = options.onAfterPrompt + this.stdin = options.stdin ?? (process.stdin as NodeJS.ReadStream) + this.stdout = options.stdout ?? process.stdout + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Check if a prompt is currently active. + */ + isActive(): boolean { + return this.isPrompting + } + + /** + * Prompt for text input using readline. + * + * @param prompt - The prompt text to display + * @returns The user's input + * @throws If input is cancelled or an error occurs + */ + async promptForInput(prompt: string): Promise { + return new Promise((resolve, reject) => { + this.beforePrompt() + this.isPrompting = true + + const rl = readline.createInterface({ + input: this.stdin, + output: this.stdout, + }) + + rl.question(prompt, (answer) => { + rl.close() + this.isPrompting = false + this.afterPrompt() + resolve(answer) + }) + + rl.on("close", () => { + this.isPrompting = false + this.afterPrompt() + }) + + rl.on("error", (err) => { + rl.close() + this.isPrompting = false + this.afterPrompt() + reject(err) + }) + }) + } + + /** + * Prompt for yes/no input. + * + * @param prompt - The prompt text to display + * @param defaultValue - Default value if empty input (default: false) + * @returns true for yes, false for no + */ + async promptForYesNo(prompt: string, defaultValue = false): Promise { + const answer = await this.promptForInput(prompt) + const normalized = answer.trim().toLowerCase() + if (normalized === "" && defaultValue !== undefined) { + return defaultValue + } + return normalized === "y" || normalized === "yes" + } + + /** + * Prompt for input with a timeout. + * Uses raw mode for character-by-character input handling. + * + * @param prompt - The prompt text to display + * @param timeoutMs - Timeout in milliseconds + * @param defaultValue - Value to use if timed out + * @returns TimedPromptResult with value, timedOut flag, and cancelled flag + */ + async promptWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise { + return new Promise((resolve) => { + this.beforePrompt() + this.isPrompting = true + + // Track the original raw mode state to restore it later + const wasRaw = this.stdin.isRaw + + // Enable raw mode for character-by-character input if TTY + if (this.stdin.isTTY) { + this.stdin.setRawMode(true) + } + + this.stdin.resume() + + let inputBuffer = "" + let timeoutCancelled = false + let resolved = false + + // Set up timeout + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true + cleanup() + this.stdout.write(`\n[Timeout - using default: ${defaultValue || "(empty)"}]\n`) + resolve({ value: defaultValue, timedOut: true, cancelled: false }) + } + }, timeoutMs) + + // Display prompt + this.stdout.write(prompt) + + // Cleanup function to restore state + const cleanup = () => { + clearTimeout(timeout) + this.stdin.removeListener("data", onData) + + if (this.stdin.isTTY && wasRaw !== undefined) { + this.stdin.setRawMode(wasRaw) + } + + this.stdin.pause() + this.isPrompting = false + this.afterPrompt() + } + + // Handle incoming data + const onData = (data: Buffer) => { + const char = data.toString() + + // Handle Ctrl+C + if (char === "\x03") { + cleanup() + resolved = true + this.stdout.write("\n[cancelled]\n") + resolve({ value: defaultValue, timedOut: false, cancelled: true }) + return + } + + // Cancel timeout on first input + if (!timeoutCancelled) { + timeoutCancelled = true + clearTimeout(timeout) + } + + // Handle Enter + if (char === "\r" || char === "\n") { + if (!resolved) { + resolved = true + cleanup() + this.stdout.write("\n") + resolve({ value: inputBuffer, timedOut: false, cancelled: false }) + } + return + } + + // Handle Backspace + if (char === "\x7f" || char === "\b") { + if (inputBuffer.length > 0) { + inputBuffer = inputBuffer.slice(0, -1) + this.stdout.write("\b \b") + } + return + } + + // Normal character - add to buffer and echo + inputBuffer += char + this.stdout.write(char) + } + + this.stdin.on("data", onData) + }) + } + + /** + * Prompt for yes/no with timeout. + * + * @param prompt - The prompt text to display + * @param timeoutMs - Timeout in milliseconds + * @param defaultValue - Default boolean value if timed out + * @returns true for yes, false for no + */ + async promptForYesNoWithTimeout(prompt: string, timeoutMs: number, defaultValue: boolean): Promise { + const result = await this.promptWithTimeout(prompt, timeoutMs, defaultValue ? "y" : "n") + const normalized = result.value.trim().toLowerCase() + if (result.timedOut || result.cancelled || normalized === "") { + return defaultValue + } + return normalized === "y" || normalized === "yes" + } + + /** + * Display a message on stdout (utility for prompting context). + */ + write(text: string): void { + this.stdout.write(text) + } + + /** + * Display a message with newline. + */ + writeLine(text: string): void { + this.stdout.write(text + "\n") + } + + // =========================================================================== + // Private Helpers + // =========================================================================== + + private beforePrompt(): void { + if (this.onBeforePrompt) { + this.onBeforePrompt() + } + } + + private afterPrompt(): void { + if (this.onAfterPrompt) { + this.onAfterPrompt() + } + } +} diff --git a/apps/cli/src/agent/state-store.ts b/apps/cli/src/agent/state-store.ts new file mode 100644 index 00000000000..68dcfc40698 --- /dev/null +++ b/apps/cli/src/agent/state-store.ts @@ -0,0 +1,415 @@ +/** + * State Store + * + * This module manages the client's internal state, including: + * - The clineMessages array (source of truth for agent state) + * - The computed agent state info + * - Any extension state we want to cache + * + * The store is designed to be: + * - Immutable: State updates create new objects, not mutations + * - Observable: Changes trigger notifications + * - Queryable: Current state is always accessible + */ + +import { ClineMessage, ExtensionState } from "@roo-code/types" + +import { detectAgentState, AgentStateInfo, AgentLoopState } from "./agent-state.js" +import { Observable } from "./events.js" + +// ============================================================================= +// Store State Interface +// ============================================================================= + +/** + * The complete state managed by the store. + */ +export interface StoreState { + /** + * The array of messages from the extension. + * This is the primary data used to compute agent state. + */ + messages: ClineMessage[] + + /** + * The computed agent state info. + * Updated automatically when messages change. + */ + agentState: AgentStateInfo + + /** + * Whether we have received any state from the extension. + * Useful to distinguish "no task" from "not yet connected". + */ + isInitialized: boolean + + /** + * The last time state was updated. + */ + lastUpdatedAt: number + + /** + * The current mode (e.g., "code", "architect", "ask"). + * Tracked from state messages received from the extension. + */ + currentMode: string | undefined + + /** + * Optional: Cache of extension state fields we might need. + * This is a subset of the full ExtensionState. + */ + extensionState?: Partial +} + +/** + * Create the initial store state. + */ +function createInitialState(): StoreState { + return { + messages: [], + agentState: detectAgentState([]), + isInitialized: false, + lastUpdatedAt: Date.now(), + currentMode: undefined, + } +} + +// ============================================================================= +// State Store Class +// ============================================================================= + +/** + * StateStore manages all client state and provides reactive updates. + * + * Key features: + * - Stores the clineMessages array + * - Automatically computes agent state when messages change + * - Provides observable pattern for state changes + * - Tracks state history for debugging (optional) + * + * Usage: + * ```typescript + * const store = new StateStore() + * + * // Subscribe to state changes + * store.subscribe((state) => { + * console.log('New state:', state.agentState.state) + * }) + * + * // Update messages + * store.setMessages(newMessages) + * + * // Query current state + * const currentState = store.getState() + * ``` + */ +export class StateStore { + private state: StoreState + private stateObservable: Observable + private agentStateObservable: Observable + + /** + * Optional: Track state history for debugging. + * Set maxHistorySize to enable. + */ + private stateHistory: StoreState[] = [] + private maxHistorySize: number + + constructor(options: { maxHistorySize?: number } = {}) { + this.state = createInitialState() + this.stateObservable = new Observable(this.state) + this.agentStateObservable = new Observable(this.state.agentState) + this.maxHistorySize = options.maxHistorySize ?? 0 + } + + // =========================================================================== + // State Queries + // =========================================================================== + + /** + * Get the current complete state. + */ + getState(): StoreState { + return this.state + } + + /** + * Get just the agent state info. + * This is a convenience method for the most common query. + */ + getAgentState(): AgentStateInfo { + return this.state.agentState + } + + /** + * Get the current messages array. + */ + getMessages(): ClineMessage[] { + return this.state.messages + } + + /** + * Get the last message, if any. + */ + getLastMessage(): ClineMessage | undefined { + return this.state.messages[this.state.messages.length - 1] + } + + /** + * Check if the store has been initialized with extension state. + */ + isInitialized(): boolean { + return this.state.isInitialized + } + + /** + * Quick check: Is the agent currently waiting for input? + */ + isWaitingForInput(): boolean { + return this.state.agentState.isWaitingForInput + } + + /** + * Quick check: Is the agent currently running? + */ + isRunning(): boolean { + return this.state.agentState.isRunning + } + + /** + * Quick check: Is content currently streaming? + */ + isStreaming(): boolean { + return this.state.agentState.isStreaming + } + + /** + * Get the current agent loop state enum value. + */ + getCurrentState(): AgentLoopState { + return this.state.agentState.state + } + + /** + * Get the current mode (e.g., "code", "architect", "ask"). + */ + getCurrentMode(): string | undefined { + return this.state.currentMode + } + + // =========================================================================== + // State Updates + // =========================================================================== + + /** + * Set the complete messages array. + * This is typically called when receiving a full state update from the extension. + * + * @param messages - The new messages array + * @returns The previous agent state (for comparison) + */ + setMessages(messages: ClineMessage[]): AgentStateInfo { + const previousAgentState = this.state.agentState + const newAgentState = detectAgentState(messages) + + this.updateState({ + messages, + agentState: newAgentState, + isInitialized: true, + lastUpdatedAt: Date.now(), + currentMode: this.state.currentMode, // Preserve mode across message updates + }) + + return previousAgentState + } + + /** + * Add a single message to the end of the messages array. + * Useful when receiving incremental updates. + * + * @param message - The message to add + * @returns The previous agent state + */ + addMessage(message: ClineMessage): AgentStateInfo { + const newMessages = [...this.state.messages, message] + return this.setMessages(newMessages) + } + + /** + * Update a message in place (e.g., when partial becomes complete). + * Finds the message by timestamp and replaces it. + * + * @param message - The updated message + * @returns The previous agent state, or undefined if message not found + */ + updateMessage(message: ClineMessage): AgentStateInfo | undefined { + const index = this.state.messages.findIndex((m) => m.ts === message.ts) + if (index === -1) { + // Message not found, add it instead + return this.addMessage(message) + } + + const newMessages = [...this.state.messages] + newMessages[index] = message + return this.setMessages(newMessages) + } + + /** + * Clear all messages and reset to initial state. + * Called when a task is cleared/cancelled. + */ + clear(): void { + this.updateState({ + messages: [], + agentState: detectAgentState([]), + isInitialized: true, // Still initialized, just empty + lastUpdatedAt: Date.now(), + currentMode: this.state.currentMode, // Preserve mode when clearing task + extensionState: undefined, + }) + } + + /** + * Set the current mode. + * Called when mode changes are detected from extension state messages. + * + * @param mode - The new mode value + */ + setCurrentMode(mode: string | undefined): void { + if (this.state.currentMode !== mode) { + this.updateState({ + ...this.state, + currentMode: mode, + lastUpdatedAt: Date.now(), + }) + } + } + + /** + * Reset to completely uninitialized state. + * Called on disconnect or reset. + */ + reset(): void { + this.state = createInitialState() + this.stateHistory = [] + // Don't notify on reset - we're starting fresh + } + + /** + * Update cached extension state. + * This stores any additional extension state fields we might need. + * + * @param extensionState - The extension state to cache + */ + setExtensionState(extensionState: Partial): void { + // Extract and store messages if present + if (extensionState.clineMessages) { + this.setMessages(extensionState.clineMessages) + } + + // Store the rest of the extension state + this.updateState({ + ...this.state, + extensionState: { + ...this.state.extensionState, + ...extensionState, + }, + }) + } + + // =========================================================================== + // Subscriptions + // =========================================================================== + + /** + * Subscribe to all state changes. + * + * @param observer - Callback function receiving the new state + * @returns Unsubscribe function + */ + subscribe(observer: (state: StoreState) => void): () => void { + return this.stateObservable.subscribe(observer) + } + + /** + * Subscribe to agent state changes only. + * This is more efficient if you only care about agent state. + * + * @param observer - Callback function receiving the new agent state + * @returns Unsubscribe function + */ + subscribeToAgentState(observer: (state: AgentStateInfo) => void): () => void { + return this.agentStateObservable.subscribe(observer) + } + + // =========================================================================== + // History (for debugging) + // =========================================================================== + + /** + * Get the state history (if enabled). + */ + getHistory(): StoreState[] { + return [...this.stateHistory] + } + + /** + * Clear the state history. + */ + clearHistory(): void { + this.stateHistory = [] + } + + // =========================================================================== + // Private Methods + // =========================================================================== + + /** + * Internal method to update state and notify observers. + */ + private updateState(newState: StoreState): void { + // Track history if enabled + if (this.maxHistorySize > 0) { + this.stateHistory.push(this.state) + if (this.stateHistory.length > this.maxHistorySize) { + this.stateHistory.shift() + } + } + + this.state = newState + + // Notify observers + this.stateObservable.next(this.state) + this.agentStateObservable.next(this.state.agentState) + } +} + +// ============================================================================= +// Singleton Store (optional convenience) +// ============================================================================= + +let defaultStore: StateStore | null = null + +/** + * Get the default singleton store instance. + * Useful for simple applications that don't need multiple stores. + */ +export function getDefaultStore(): StateStore { + if (!defaultStore) { + defaultStore = new StateStore() + } + + return defaultStore +} + +/** + * Reset the default store instance. + * Useful for testing or when you need a fresh start. + */ +export function resetDefaultStore(): void { + if (defaultStore) { + defaultStore.reset() + } + + defaultStore = null +} diff --git a/apps/cli/src/commands/cli/__tests__/cancellation.test.ts b/apps/cli/src/commands/cli/__tests__/cancellation.test.ts new file mode 100644 index 00000000000..13cfa9aaea0 --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/cancellation.test.ts @@ -0,0 +1,104 @@ +import { + isCancellationLikeError, + isExpectedControlFlowError, + isNoActiveTaskLikeError, + isStreamTeardownLikeError, +} from "../cancellation.js" + +describe("isCancellationLikeError", () => { + it("returns true for aborted error messages", () => { + expect(isCancellationLikeError(new Error("[RooCode#say] task 123 aborted"))).toBe(true) + expect(isCancellationLikeError("AbortError: operation aborted")).toBe(true) + }) + + it("returns true for abort/cancel error names and codes", () => { + expect(isCancellationLikeError({ name: "AbortError", message: "stop now" })).toBe(true) + expect(isCancellationLikeError({ code: "ABORT_ERR", message: "aborted" })).toBe(true) + expect(isCancellationLikeError({ code: "ERR_CANCELED", message: "request failed" })).toBe(true) + }) + + it("returns true for canceled/cancelled error messages", () => { + expect(isCancellationLikeError(new Error("Request canceled"))).toBe(true) + expect(isCancellationLikeError(new Error("request cancelled by user"))).toBe(true) + }) + + it("returns false for non-cancellation errors", () => { + expect(isCancellationLikeError(new Error("network timeout"))).toBe(false) + expect(isCancellationLikeError("validation failed")).toBe(false) + }) +}) + +describe("isNoActiveTaskLikeError", () => { + it("matches task-settled cancel race messages", () => { + expect(isNoActiveTaskLikeError(new Error("no active task to cancel"))).toBe(true) + expect(isNoActiveTaskLikeError(new Error("task not found"))).toBe(true) + expect(isNoActiveTaskLikeError("already completed")).toBe(true) + }) + + it("does not match unrelated messages", () => { + expect(isNoActiveTaskLikeError("network timeout")).toBe(false) + }) +}) + +describe("isStreamTeardownLikeError", () => { + it("matches common stream teardown errors", () => { + expect(isStreamTeardownLikeError({ code: "EPIPE", message: "broken pipe" })).toBe(true) + expect(isStreamTeardownLikeError({ code: "ERR_STREAM_DESTROYED", message: "stream destroyed" })).toBe(true) + expect(isStreamTeardownLikeError(new Error("write after end"))).toBe(true) + }) + + it("does not match unrelated stream errors", () => { + expect(isStreamTeardownLikeError(new Error("permission denied"))).toBe(false) + }) +}) + +describe("isExpectedControlFlowError", () => { + it("returns false when not in stdin stream mode", () => { + expect( + isExpectedControlFlowError(new Error("AbortError: aborted"), { + stdinStreamMode: false, + operation: "runtime", + }), + ).toBe(false) + }) + + it("accepts cancellation-like runtime errors in stdin stream mode", () => { + expect( + isExpectedControlFlowError(new Error("AbortError: aborted"), { + stdinStreamMode: true, + operation: "runtime", + }), + ).toBe(true) + }) + + it("accepts no-active-task races for cancel operations", () => { + expect( + isExpectedControlFlowError(new Error("task not found"), { + stdinStreamMode: true, + operation: "cancel", + }), + ).toBe(true) + }) + + it("accepts stream teardown errors during shutdown", () => { + expect( + isExpectedControlFlowError( + { code: "EPIPE", message: "broken pipe" }, + { + stdinStreamMode: true, + shuttingDown: true, + operation: "runtime", + }, + ), + ).toBe(true) + }) + + it("rejects unrelated errors", () => { + expect( + isExpectedControlFlowError(new Error("authentication failed"), { + stdinStreamMode: true, + operation: "runtime", + }), + ).toBe(false) + }) +}) diff --git a/apps/cli/src/commands/cli/__tests__/list.test.ts b/apps/cli/src/commands/cli/__tests__/list.test.ts new file mode 100644 index 00000000000..5058b8e8d85 --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/list.test.ts @@ -0,0 +1,84 @@ +import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js" + +import { listSessions, parseFormat } from "../list.js" + +vi.mock("@/lib/task-history/index.js", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readWorkspaceTaskSessions: vi.fn(), + } +}) + +describe("parseFormat", () => { + it("defaults to json when undefined", () => { + expect(parseFormat(undefined)).toBe("json") + }) + + it("returns json for 'json'", () => { + expect(parseFormat("json")).toBe("json") + }) + + it("returns text for 'text'", () => { + expect(parseFormat("text")).toBe("text") + }) + + it("is case-insensitive", () => { + expect(parseFormat("JSON")).toBe("json") + expect(parseFormat("Text")).toBe("text") + expect(parseFormat("TEXT")).toBe("text") + }) + + it("throws on invalid format", () => { + expect(() => parseFormat("xml")).toThrow('Invalid format: xml. Must be "json" or "text".') + }) + + it("throws on empty string", () => { + expect(() => parseFormat("")).toThrow("Invalid format") + }) +}) + +describe("listSessions", () => { + const workspacePath = process.cwd() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const captureStdout = async (fn: () => Promise): Promise => { + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true) + + try { + await fn() + return stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("") + } finally { + stdoutSpy.mockRestore() + } + } + + it("uses the CLI runtime storage path and prints JSON output", async () => { + vi.mocked(readWorkspaceTaskSessions).mockResolvedValue([ + { id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }, + ]) + + const output = await captureStdout(() => listSessions({ format: "json", workspace: workspacePath })) + + expect(readWorkspaceTaskSessions).toHaveBeenCalledWith(workspacePath) + expect(JSON.parse(output)).toEqual({ + workspace: workspacePath, + sessions: [{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }], + }) + }) + + it("prints tab-delimited text output with ISO timestamps and formatted titles", async () => { + vi.mocked(readWorkspaceTaskSessions).mockResolvedValue([ + { id: "s1", task: "Task 1", ts: Date.UTC(2024, 0, 1, 0, 0, 0) }, + { id: "s2", task: " ", ts: Date.UTC(2024, 0, 1, 1, 0, 0) }, + ]) + + const output = await captureStdout(() => listSessions({ format: "text", workspace: workspacePath })) + const lines = output.trim().split("\n") + + expect(lines).toEqual(["s1\t2024-01-01T00:00:00.000Z\tTask 1", "s2\t2024-01-01T01:00:00.000Z\t(untitled)"]) + }) +}) diff --git a/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts b/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts new file mode 100644 index 00000000000..3656ac6ce18 --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts @@ -0,0 +1,247 @@ +import { parseStdinStreamCommand, shouldSendMessageAsAskResponse } from "../stdin-stream.js" + +describe("parseStdinStreamCommand", () => { + describe("valid commands", () => { + it("parses a start command", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "start", requestId: "req-1", prompt: "hello" }), + 1, + ) + expect(result).toEqual({ command: "start", requestId: "req-1", prompt: "hello" }) + }) + + it("parses a start command with taskId", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-task-id", + prompt: "hello", + taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87", + }), + 1, + ) + expect(result).toEqual({ + command: "start", + requestId: "req-task-id", + prompt: "hello", + taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87", + }) + }) + + it("parses a message command", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "message", requestId: "req-2", prompt: "follow up" }), + 1, + ) + expect(result).toEqual({ command: "message", requestId: "req-2", prompt: "follow up" }) + }) + + it("parses start and message images", () => { + const start = parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-img-start", + prompt: "hello", + images: ["data:image/jpeg;base64,abc123"], + }), + 1, + ) + expect(start).toEqual({ + command: "start", + requestId: "req-img-start", + prompt: "hello", + images: ["data:image/jpeg;base64,abc123"], + }) + + const message = parseStdinStreamCommand( + JSON.stringify({ + command: "message", + requestId: "req-img-msg", + prompt: "follow up", + images: ["data:image/png;base64,xyz456"], + }), + 1, + ) + expect(message).toEqual({ + command: "message", + requestId: "req-img-msg", + prompt: "follow up", + images: ["data:image/png;base64,xyz456"], + }) + }) + + it.each(["cancel", "ping", "shutdown"] as const)("parses a %s command (no prompt required)", (command) => { + const result = parseStdinStreamCommand(JSON.stringify({ command, requestId: "req-3" }), 1) + expect(result).toEqual({ command, requestId: "req-3" }) + }) + + it("trims whitespace from requestId", () => { + const result = parseStdinStreamCommand(JSON.stringify({ command: "ping", requestId: " req-4 " }), 1) + expect(result.requestId).toBe("req-4") + }) + + it("ignores extra fields", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "ping", requestId: "req-5", extra: "ignored", nested: { a: 1 } }), + 1, + ) + expect(result).toEqual({ command: "ping", requestId: "req-5" }) + }) + }) + + describe("invalid input", () => { + it("throws on invalid JSON", () => { + expect(() => parseStdinStreamCommand("not json", 3)).toThrow("stdin command line 3: invalid JSON") + }) + + it("throws on non-object JSON (string)", () => { + expect(() => parseStdinStreamCommand('"hello"', 1)).toThrow("expected JSON object") + }) + + it("throws on non-object JSON (array)", () => { + // Arrays pass isRecord (typeof [] === "object") but lack a command field + expect(() => parseStdinStreamCommand("[]", 1)).toThrow('missing string "command"') + }) + + it("throws on non-object JSON (number)", () => { + expect(() => parseStdinStreamCommand("42", 1)).toThrow("expected JSON object") + }) + + it("throws on null", () => { + expect(() => parseStdinStreamCommand("null", 1)).toThrow("expected JSON object") + }) + + it("throws when command field is missing", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ requestId: "req" }), 5)).toThrow( + 'stdin command line 5: missing string "command"', + ) + }) + + it("throws when command is not a string", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: 123, requestId: "req" }), 1)).toThrow( + 'missing string "command"', + ) + }) + + it("throws on unsupported command name", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "unknown", requestId: "req" }), 2)).toThrow( + 'stdin command line 2: unsupported command "unknown"', + ) + }) + + it("throws when requestId is missing", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "ping" }), 1)).toThrow( + 'missing non-empty string "requestId"', + ) + }) + + it("throws when requestId is empty", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "ping", requestId: " " }), 1)).toThrow( + 'missing non-empty string "requestId"', + ) + }) + + it("throws when start command has no prompt", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "start", requestId: "req" }), 1)).toThrow( + '"start" requires non-empty string "prompt"', + ) + }) + + it("throws when start taskId is empty, not a string, or not a UUID", () => { + expect(() => + parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-start-task-id-empty", + prompt: "hello", + taskId: " ", + }), + 1, + ), + ).toThrow('"start" taskId must be a non-empty string') + + expect(() => + parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-start-task-id-num", + prompt: "hello", + taskId: 123, + }), + 1, + ), + ).toThrow('"start" taskId must be a non-empty string') + + expect(() => + parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-start-task-id-invalid-format", + prompt: "hello", + taskId: "task-123", + }), + 1, + ), + ).toThrow('"start" taskId must be a valid UUID') + }) + + it("throws when message command has empty prompt", () => { + expect(() => + parseStdinStreamCommand(JSON.stringify({ command: "message", requestId: "req", prompt: " " }), 1), + ).toThrow('"message" requires non-empty string "prompt"') + }) + + it("throws when start or message images are not string arrays", () => { + expect(() => + parseStdinStreamCommand( + JSON.stringify({ + command: "start", + requestId: "req-start-img", + prompt: "hello", + images: "not-an-array", + }), + 1, + ), + ).toThrow('"start" images must be an array of strings') + + expect(() => + parseStdinStreamCommand( + JSON.stringify({ + command: "message", + requestId: "req-msg-img", + prompt: "follow up", + images: ["ok", 123], + }), + 1, + ), + ).toThrow('"message" images must be an array of strings') + }) + }) +}) + +describe("shouldSendMessageAsAskResponse", () => { + it("routes completion_result asks as ask responses", () => { + expect(shouldSendMessageAsAskResponse(true, "completion_result")).toBe(true) + }) + + it.each([ + "followup", + "tool", + "command", + "use_mcp_server", + "resume_task", + "resume_completed_task", + "mistake_limit_reached", + ])("routes %s asks as ask responses", (ask) => { + expect(shouldSendMessageAsAskResponse(true, ask)).toBe(true) + }) + + it("does not route when not waiting for input", () => { + expect(shouldSendMessageAsAskResponse(false, "completion_result")).toBe(false) + }) + + it("does not route unknown asks", () => { + expect(shouldSendMessageAsAskResponse(true, "unknown")).toBe(false) + expect(shouldSendMessageAsAskResponse(true, undefined)).toBe(false) + }) +}) diff --git a/apps/cli/src/commands/cli/__tests__/run.test.ts b/apps/cli/src/commands/cli/__tests__/run.test.ts new file mode 100644 index 00000000000..7b7693a39cd --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/run.test.ts @@ -0,0 +1,93 @@ +import fs from "fs" +import path from "path" +import os from "os" + +describe("run command --prompt-file option", () => { + let tempDir: string + let promptFilePath: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-test-")) + promptFilePath = path.join(tempDir, "prompt.md") + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it("should read prompt from file when --prompt-file is provided", () => { + const promptContent = `This is a test prompt with special characters: +- Quotes: "hello" and 'world' +- Backticks: \`code\` +- Newlines and tabs +- Unicode: 你好 🎉` + + fs.writeFileSync(promptFilePath, promptContent) + + // Verify the file was written correctly + const readContent = fs.readFileSync(promptFilePath, "utf-8") + expect(readContent).toBe(promptContent) + }) + + it("should handle multi-line prompts correctly", () => { + const multiLinePrompt = `Line 1 +Line 2 +Line 3 + +Empty line above +\tTabbed line + Indented line` + + fs.writeFileSync(promptFilePath, multiLinePrompt) + const readContent = fs.readFileSync(promptFilePath, "utf-8") + + expect(readContent).toBe(multiLinePrompt) + expect(readContent.split("\n")).toHaveLength(7) + }) + + it("should handle very long prompts that would exceed ARG_MAX", () => { + // ARG_MAX is typically 128KB-2MB, so let's test with a 500KB prompt + const longPrompt = "x".repeat(500 * 1024) + + fs.writeFileSync(promptFilePath, longPrompt) + const readContent = fs.readFileSync(promptFilePath, "utf-8") + + expect(readContent.length).toBe(500 * 1024) + expect(readContent).toBe(longPrompt) + }) + + it("should preserve shell-sensitive characters", () => { + const shellSensitivePrompt = ` +$HOME +$(echo dangerous) +\`rm -rf /\` +"quoted string" +'single quoted' +$((1+1)) +&& +|| +; +> /dev/null +< input.txt +| grep something +* +? +[abc] +{a,b} +~ +! +#comment +%s +\n\t\r +` + + fs.writeFileSync(promptFilePath, shellSensitivePrompt) + const readContent = fs.readFileSync(promptFilePath, "utf-8") + + // All shell-sensitive characters should be preserved exactly + expect(readContent).toBe(shellSensitivePrompt) + expect(readContent).toContain("$HOME") + expect(readContent).toContain("$(echo dangerous)") + expect(readContent).toContain("`rm -rf /`") + }) +}) diff --git a/apps/cli/src/commands/cli/__tests__/upgrade.test.ts b/apps/cli/src/commands/cli/__tests__/upgrade.test.ts new file mode 100644 index 00000000000..71fc39dd3e4 --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/upgrade.test.ts @@ -0,0 +1,93 @@ +import { compareVersions, getLatestCliVersion, upgrade } from "../upgrade.js" + +function createFetchResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response { + const { ok = true, status = 200 } = init + return { + ok, + status, + json: async () => body, + } as Response +} + +describe("compareVersions", () => { + it("returns 1 when first version is newer", () => { + expect(compareVersions("0.2.0", "0.1.9")).toBe(1) + }) + + it("returns -1 when first version is older", () => { + expect(compareVersions("0.1.4", "0.1.5")).toBe(-1) + }) + + it("returns 0 when versions are equivalent", () => { + expect(compareVersions("v1.2.0", "1.2")).toBe(0) + }) + + it("supports cli tag prefixes and prerelease metadata", () => { + expect(compareVersions("cli-v1.2.3", "1.2.2")).toBe(1) + expect(compareVersions("1.2.3-beta.1", "1.2.3")).toBe(0) + }) + + it("compares multi-digit patch versions numerically", () => { + expect(compareVersions("0.1.10", "0.1.9")).toBe(1) + }) +}) + +describe("getLatestCliVersion", () => { + it("returns the highest cli-v release tag from GitHub releases", async () => { + const fetchImpl = (async () => + createFetchResponse([ + { tag_name: "cli-v0.1.9" }, + { tag_name: "v9.9.9" }, + { tag_name: "cli-v0.1.10" }, + { tag_name: "cli-v0.1.8" }, + ])) as typeof fetch + + await expect(getLatestCliVersion(fetchImpl)).resolves.toBe("0.1.10") + }) + + it("throws when release check fails", async () => { + const fetchImpl = (async () => createFetchResponse({}, { ok: false, status: 503 })) as typeof fetch + + await expect(getLatestCliVersion(fetchImpl)).rejects.toThrow("Failed to check latest version") + }) +}) + +describe("upgrade", () => { + let logSpy: ReturnType + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined) + }) + + afterEach(() => { + logSpy.mockRestore() + }) + + it("does not run installer when already up to date", async () => { + const runInstaller = vi.fn(async () => undefined) + const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.1.4" }])) as typeof fetch + + await upgrade({ + currentVersion: "0.1.4", + fetchImpl, + runInstaller, + }) + + expect(runInstaller).not.toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith("Roo CLI is already up to date.") + }) + + it("runs installer when a newer version is available", async () => { + const runInstaller = vi.fn(async () => undefined) + const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.2.0" }])) as typeof fetch + + await upgrade({ + currentVersion: "0.1.4", + fetchImpl, + runInstaller, + }) + + expect(runInstaller).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledWith("✓ Upgrade completed.") + }) +}) diff --git a/apps/cli/src/commands/cli/cancellation.ts b/apps/cli/src/commands/cli/cancellation.ts new file mode 100644 index 00000000000..402fb93a4dc --- /dev/null +++ b/apps/cli/src/commands/cli/cancellation.ts @@ -0,0 +1,131 @@ +const CANCELLATION_ERROR_PATTERNS = ["aborted", "aborterror", "cancelled", "canceled"] +const CANCELLATION_ERROR_NAMES = new Set(["aborterror"]) +const CANCELLATION_ERROR_CODES = new Set(["ABORT_ERR", "ERR_CANCELED", "ERR_CANCELLED"]) +const NO_ACTIVE_TASK_PATTERNS = [ + "no active task", + "no task to cancel", + "task not found", + "unable to find task", + "already completed", + "already cancelled", + "already canceled", +] +const STREAM_TEARDOWN_CODES = new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_DESTROYED", "ERR_STREAM_PREMATURE_CLOSE"]) +const STREAM_TEARDOWN_PATTERNS = [ + "write after end", + "stream destroyed", + "premature close", + "socket hang up", + "broken pipe", +] + +export interface ExpectedControlFlowErrorContext { + stdinStreamMode: boolean + cancelRequested?: boolean + shuttingDown?: boolean + operation?: "runtime" | "client" | "cancel" | "shutdown" +} + +interface ErrorMetadata { + message: string + normalizedMessage: string + name?: string + normalizedName?: string + code?: string +} + +function getErrorMetadata(error: unknown): ErrorMetadata { + if (error instanceof Error) { + const maybeCode = (error as Error & { code?: unknown }).code + const code = typeof maybeCode === "string" ? maybeCode : undefined + return { + message: error.message, + normalizedMessage: error.message.toLowerCase(), + name: error.name, + normalizedName: error.name.toLowerCase(), + code, + } + } + + if (typeof error === "object" && error !== null) { + const nameRaw = (error as { name?: unknown }).name + const messageRaw = (error as { message?: unknown }).message + const codeRaw = (error as { code?: unknown }).code + const message = typeof messageRaw === "string" ? messageRaw : String(error) + return { + message, + normalizedMessage: message.toLowerCase(), + name: typeof nameRaw === "string" ? nameRaw : undefined, + normalizedName: typeof nameRaw === "string" ? nameRaw.toLowerCase() : undefined, + code: typeof codeRaw === "string" ? codeRaw : undefined, + } + } + + const message = String(error) + return { + message, + normalizedMessage: message.toLowerCase(), + } +} + +/** + * Best-effort classifier for cancellation/abort failures. + */ +export function isCancellationLikeError(error: unknown): boolean { + const details = getErrorMetadata(error) + + if (details.code && CANCELLATION_ERROR_CODES.has(details.code)) { + return true + } + + if (details.normalizedName && CANCELLATION_ERROR_NAMES.has(details.normalizedName)) { + return true + } + + return CANCELLATION_ERROR_PATTERNS.some((pattern) => details.normalizedMessage.includes(pattern)) +} + +export function isNoActiveTaskLikeError(error: unknown): boolean { + const details = getErrorMetadata(error) + return NO_ACTIVE_TASK_PATTERNS.some((pattern) => details.normalizedMessage.includes(pattern)) +} + +export function isStreamTeardownLikeError(error: unknown): boolean { + const details = getErrorMetadata(error) + if (details.code && STREAM_TEARDOWN_CODES.has(details.code)) { + return true + } + + return STREAM_TEARDOWN_PATTERNS.some((pattern) => details.normalizedMessage.includes(pattern)) +} + +/** + * Classify errors that should be treated as expected control flow rather than + * fatal failures while handling stdin stream tasks. + */ +export function isExpectedControlFlowError(error: unknown, context: ExpectedControlFlowErrorContext): boolean { + if (!context.stdinStreamMode) { + return false + } + + if (context.shuttingDown && isStreamTeardownLikeError(error)) { + return true + } + + const isCancelLike = isCancellationLikeError(error) + if (isCancelLike && (context.cancelRequested || context.shuttingDown || context.operation === "runtime")) { + return true + } + + if ( + isNoActiveTaskLikeError(error) && + (context.cancelRequested || + context.shuttingDown || + context.operation === "cancel" || + context.operation === "shutdown") + ) { + return true + } + + return false +} diff --git a/apps/cli/src/commands/cli/index.ts b/apps/cli/src/commands/cli/index.ts new file mode 100644 index 00000000000..b59f1ebfa82 --- /dev/null +++ b/apps/cli/src/commands/cli/index.ts @@ -0,0 +1,3 @@ +export * from "./run.js" +export * from "./list.js" +export * from "./upgrade.js" diff --git a/apps/cli/src/commands/cli/list.ts b/apps/cli/src/commands/cli/list.ts new file mode 100644 index 00000000000..1489a5feb94 --- /dev/null +++ b/apps/cli/src/commands/cli/list.ts @@ -0,0 +1,312 @@ +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +import pWaitFor from "p-wait-for" + +import type { TaskSessionEntry } from "@roo-code/core/cli" +import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types" +import { openRouterDefaultModelId } from "@roo-code/types" + +import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js" +import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js" +import { getDefaultExtensionPath } from "@/lib/utils/extension.js" +import { getApiKeyFromEnv } from "@/lib/utils/provider.js" +import { isRecord } from "@/lib/utils/guards.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +const REQUEST_TIMEOUT_MS = 10_000 + +type ListFormat = "json" | "text" + +type BaseListOptions = { + workspace?: string + extension?: string + apiKey?: string + format?: string + debug?: boolean +} + +type CommandLike = Pick +type ModeLike = { slug: string; name: string } +type SessionLike = TaskSessionEntry +type ListHostOptions = { ephemeral: boolean } + +export function parseFormat(rawFormat: string | undefined): ListFormat { + const format = (rawFormat ?? "json").toLowerCase() + if (format === "json" || format === "text") { + return format + } + + throw new Error(`Invalid format: ${rawFormat}. Must be "json" or "text".`) +} + +function resolveWorkspacePath(workspace: string | undefined): string { + const resolved = workspace ? path.resolve(workspace) : process.cwd() + + if (!fs.existsSync(resolved)) { + throw new Error(`Workspace path does not exist: ${resolved}`) + } + + return resolved +} + +function resolveExtensionPath(extension: string | undefined): string { + const resolved = path.resolve(extension || getDefaultExtensionPath(__dirname)) + + if (!fs.existsSync(path.join(resolved, "extension.js"))) { + throw new Error(`Extension bundle not found at: ${resolved}`) + } + + return resolved +} + +function outputJson(data: unknown): void { + process.stdout.write(JSON.stringify(data, null, 2) + "\n") +} + +function outputCommandsText(commands: CommandLike[]): void { + for (const command of commands) { + const description = command.description ? ` - ${command.description}` : "" + process.stdout.write(`/${command.name} (${command.source})${description}\n`) + } +} + +function outputModesText(modes: ModeLike[]): void { + for (const mode of modes) { + process.stdout.write(`${mode.slug}\t${mode.name}\n`) + } +} + +function outputModelsText(models: ModelRecord): void { + for (const modelId of Object.keys(models).sort()) { + process.stdout.write(`${modelId}\n`) + } +} + +function formatSessionTitle(task: string): string { + const compact = task.replace(/\s+/g, " ").trim() + + if (!compact) { + return "(untitled)" + } + + return compact.length <= 120 ? compact : `${compact.slice(0, 117)}...` +} + +function outputSessionsText(sessions: SessionLike[]): void { + for (const session of sessions) { + const startedAt = Number.isFinite(session.ts) ? new Date(session.ts).toISOString() : "unknown-time" + process.stdout.write(`${session.id}\t${startedAt}\t${formatSessionTitle(session.task)}\n`) + } +} + +async function createListHost(options: BaseListOptions, hostOptions: ListHostOptions): Promise { + const workspacePath = resolveWorkspacePath(options.workspace) + const extensionPath = resolveExtensionPath(options.extension) + const apiKey = options.apiKey || getApiKeyFromEnv("openrouter") + + const extensionHostOptions: ExtensionHostOptions = { + mode: "code", + reasoningEffort: undefined, + user: null, + provider: "openrouter", + model: openRouterDefaultModelId, + apiKey, + workspacePath, + extensionPath, + nonInteractive: true, + ephemeral: hostOptions.ephemeral, + debug: options.debug ?? false, + exitOnComplete: true, + exitOnError: false, + disableOutput: true, + } + + const host = new ExtensionHost(extensionHostOptions) + + await host.activate() + + // Best effort wait; mode/commands requests can still succeed without this. + await pWaitFor(() => host.client.isInitialized(), { + interval: 25, + timeout: 2_000, + }).catch(() => undefined) + + return host +} + +/** + * Send a request to the extension and wait for a matching response message. + * Returns `undefined` from `extract` to skip non-matching messages, or the + * parsed value to resolve the promise. + */ +function requestFromExtension( + host: ExtensionHost, + requestType: WebviewMessage["type"], + extract: (message: Record) => T | undefined, +): Promise { + return new Promise((resolve, reject) => { + let settled = false + + const cleanup = () => { + clearTimeout(timeoutId) + host.off("extensionWebviewMessage", onMessage) + offError() + } + + const finish = (fn: () => void) => { + if (settled) return + settled = true + cleanup() + fn() + } + + const onMessage = (message: unknown) => { + if (!isRecord(message)) { + return + } + + let result: T | undefined + try { + result = extract(message) + } catch (error) { + finish(() => reject(error instanceof Error ? error : new Error(String(error)))) + return + } + + if (result !== undefined) { + finish(() => resolve(result)) + } + } + + const offError = host.client.on("error", (error) => { + finish(() => reject(error)) + }) + + const timeoutId = setTimeout(() => { + finish(() => + reject(new Error(`Timed out waiting for ${requestType} response after ${REQUEST_TIMEOUT_MS}ms`)), + ) + }, REQUEST_TIMEOUT_MS) + + host.on("extensionWebviewMessage", onMessage) + host.sendToExtension({ type: requestType }) + }) +} + +function requestCommands(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestCommands", (message) => { + if (message.type !== "commands") { + return undefined + } + return Array.isArray(message.commands) ? (message.commands as CommandLike[]) : [] + }) +} + +function requestModes(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestModes", (message) => { + if (message.type !== "modes") { + return undefined + } + return Array.isArray(message.modes) ? (message.modes as ModeLike[]) : [] + }) +} + +function requestOpenRouterModels(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestRouterModels", (message) => { + if (message.type !== "routerModels") { + return undefined + } + + const routerModels = isRecord(message.routerModels) ? message.routerModels : {} + const openRouterModels = routerModels.openrouter + return isRecord(openRouterModels) ? (openRouterModels as ModelRecord) : {} + }) +} + +async function withHostAndSignalHandlers( + options: BaseListOptions, + hostOptions: ListHostOptions, + fn: (host: ExtensionHost) => Promise, +): Promise { + const host = await createListHost(options, hostOptions) + + const shutdown = async (exitCode: number) => { + await host.dispose() + process.exit(exitCode) + } + + const onSigint = () => void shutdown(130) + const onSigterm = () => void shutdown(143) + + process.on("SIGINT", onSigint) + process.on("SIGTERM", onSigterm) + + try { + return await fn(host) + } finally { + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + await host.dispose() + } +} + +export async function listCommands(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => { + const commands = await requestCommands(host) + + if (format === "json") { + outputJson({ commands }) + return + } + + outputCommandsText(commands) + }) +} + +export async function listModes(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => { + const modes = await requestModes(host) + + if (format === "json") { + outputJson({ modes }) + return + } + + outputModesText(modes) + }) +} + +export async function listModels(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => { + const models = await requestOpenRouterModels(host) + + if (format === "json") { + outputJson({ models }) + return + } + + outputModelsText(models) + }) +} + +export async function listSessions(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + const workspacePath = resolveWorkspacePath(options.workspace) + const sessions = await readWorkspaceTaskSessions(workspacePath) + + if (format === "json") { + outputJson({ workspace: workspacePath, sessions }) + return + } + + outputSessionsText(sessions) +} diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts new file mode 100644 index 00000000000..f16d41cab18 --- /dev/null +++ b/apps/cli/src/commands/cli/run.ts @@ -0,0 +1,558 @@ +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +import { createElement } from "react" +import pWaitFor from "p-wait-for" + +import { setLogger } from "@roo-code/vscode-shim" + +import { + FlagOptions, + isSupportedProvider, + supportedProviders, + DEFAULT_FLAGS, + REASONING_EFFORTS, + OutputFormat, +} from "@/types/index.js" +import { isValidOutputFormat } from "@/types/json-events.js" +import { JsonEventEmitter } from "@/agent/json-event-emitter.js" + +import { loadSettings } from "@/lib/storage/index.js" +import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/lib/task-history/index.js" +import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" +import { validateTerminalShellPath } from "@/lib/utils/shell.js" +import { getDefaultExtensionPath } from "@/lib/utils/extension.js" +import { isValidSessionId } from "@/lib/utils/session-id.js" +import { VERSION } from "@/lib/utils/version.js" + +import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js" +import { isExpectedControlFlowError } from "./cancellation.js" +import { runStdinStreamMode } from "./stdin-stream.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const SIGNAL_ONLY_EXIT_KEEPALIVE_MS = 60_000 +const STREAM_RESUME_WAIT_TIMEOUT_MS = 2_000 + +async function bootstrapResumeForStdinStream(host: ExtensionHost, sessionId: string): Promise { + host.sendToExtension({ type: "showTaskWithId", text: sessionId }) + + // Best-effort wait so early stdin "message" commands can target the resumed task. + await pWaitFor(() => host.client.hasActiveTask() || host.isWaitingForInput(), { + interval: 25, + timeout: STREAM_RESUME_WAIT_TIMEOUT_MS, + }).catch(() => undefined) +} + +function normalizeError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { + setLogger({ + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }) + + let prompt = promptArg + + if (flagOptions.promptFile) { + if (!fs.existsSync(flagOptions.promptFile)) { + console.error(`[CLI] Error: Prompt file does not exist: ${flagOptions.promptFile}`) + process.exit(1) + } + + prompt = fs.readFileSync(flagOptions.promptFile, "utf-8") + } + + const requestedSessionId = flagOptions.sessionId?.trim() + const requestedCreateSessionId = flagOptions.createWithSessionId?.trim() + const shouldContinueSession = flagOptions.continue + const isResumeRequested = Boolean(requestedSessionId || shouldContinueSession) + + if (flagOptions.createWithSessionId !== undefined && !requestedCreateSessionId) { + console.error("[CLI] Error: --create-with-session-id requires a non-empty session id") + process.exit(1) + } + + if (flagOptions.sessionId !== undefined && !requestedSessionId) { + console.error("[CLI] Error: --session-id requires a non-empty session id") + process.exit(1) + } + + if (requestedCreateSessionId && !isValidSessionId(requestedCreateSessionId)) { + console.error("[CLI] Error: --create-with-session-id must be a valid UUID session id") + process.exit(1) + } + + if (requestedSessionId && !isValidSessionId(requestedSessionId)) { + console.error("[CLI] Error: --session-id must be a valid UUID session id") + process.exit(1) + } + + if (requestedCreateSessionId && isResumeRequested) { + console.error("[CLI] Error: cannot use --create-with-session-id with --session-id/--continue") + process.exit(1) + } + + if (requestedSessionId && shouldContinueSession) { + console.error("[CLI] Error: cannot use --session-id with --continue") + process.exit(1) + } + + if (isResumeRequested && prompt) { + console.error("[CLI] Error: cannot use prompt or --prompt-file with --session-id/--continue") + console.error("[CLI] Usage: roo [--session-id | --continue] [options]") + process.exit(1) + } + + // Options + + const settings = await loadSettings() + + const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY + const isTuiEnabled = !flagOptions.print && isTuiSupported + + // Determine effective values: CLI flags > settings file > DEFAULT_FLAGS. + const effectiveMode = flagOptions.mode || settings.mode || DEFAULT_FLAGS.mode + const effectiveModel = flagOptions.model || settings.model || DEFAULT_FLAGS.model + const effectiveReasoningEffort = + flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort + const effectiveProvider = flagOptions.provider ?? settings.provider ?? "openrouter" + const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd() + const legacyRequireApprovalFromSettings = + settings.requireApproval ?? + (settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions) + const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false + const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false + const rawConsecutiveMistakeLimit = + flagOptions.consecutiveMistakeLimit ?? settings.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit + const effectiveConsecutiveMistakeLimit = Number(rawConsecutiveMistakeLimit) + + if (!Number.isInteger(effectiveConsecutiveMistakeLimit) || effectiveConsecutiveMistakeLimit < 0) { + console.error( + `[CLI] Error: Invalid consecutive mistake limit: ${rawConsecutiveMistakeLimit}; must be a non-negative integer`, + ) + process.exit(1) + } + + let terminalShell: string | undefined + if (flagOptions.terminalShell !== undefined) { + const validatedTerminalShell = await validateTerminalShellPath(flagOptions.terminalShell) + + if (!validatedTerminalShell.valid) { + console.error( + `[CLI] Warning: ignoring --terminal-shell "${flagOptions.terminalShell}" (${validatedTerminalShell.reason})`, + ) + } else { + terminalShell = validatedTerminalShell.shellPath + } + } + + const extensionHostOptions: ExtensionHostOptions = { + mode: effectiveMode, + reasoningEffort: effectiveReasoningEffort === "unspecified" ? undefined : effectiveReasoningEffort, + consecutiveMistakeLimit: effectiveConsecutiveMistakeLimit, + user: null, + provider: effectiveProvider, + model: effectiveModel, + workspacePath: effectiveWorkspacePath, + extensionPath: path.resolve(flagOptions.extension || getDefaultExtensionPath(__dirname)), + nonInteractive: !effectiveRequireApproval, + exitOnError: flagOptions.exitOnError, + ephemeral: flagOptions.ephemeral, + debug: flagOptions.debug, + exitOnComplete: effectiveExitOnComplete, + terminalShell, + } + + // Validations + // TODO: Validate the API key for the chosen provider. + // TODO: Validate the model for the chosen provider. + + if (!isSupportedProvider(extensionHostOptions.provider)) { + console.error( + `[CLI] Error: Invalid provider: ${extensionHostOptions.provider}; must be one of: ${supportedProviders.join(", ")}`, + ) + process.exit(1) + } + + extensionHostOptions.apiKey = + extensionHostOptions.apiKey || flagOptions.apiKey || getApiKeyFromEnv(extensionHostOptions.provider) + + if (!extensionHostOptions.apiKey) { + console.error(`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`) + console.error(`[CLI] For ${extensionHostOptions.provider}, set ${getEnvVarName(extensionHostOptions.provider)}`) + + process.exit(1) + } + + if (!fs.existsSync(extensionHostOptions.workspacePath)) { + console.error(`[CLI] Error: Workspace path does not exist: ${extensionHostOptions.workspacePath}`) + process.exit(1) + } + + if (extensionHostOptions.reasoningEffort && !REASONING_EFFORTS.includes(extensionHostOptions.reasoningEffort)) { + console.error( + `[CLI] Error: Invalid reasoning effort: ${extensionHostOptions.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`, + ) + process.exit(1) + } + + // Validate output format + const outputFormat: OutputFormat = (flagOptions.outputFormat as OutputFormat) || "text" + + if (!isValidOutputFormat(outputFormat)) { + console.error( + `[CLI] Error: Invalid output format: ${flagOptions.outputFormat}; must be one of: text, json, stream-json`, + ) + process.exit(1) + } + + // Output format only works with --print mode + if (outputFormat !== "text" && !flagOptions.print && isTuiSupported) { + console.error("[CLI] Error: --output-format requires --print mode") + console.error("[CLI] Usage: roo --print --output-format json") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && !flagOptions.print) { + console.error("[CLI] Error: --stdin-prompt-stream requires --print mode") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") + process.exit(1) + } + + if (flagOptions.signalOnlyExit && !flagOptions.stdinPromptStream) { + console.error("[CLI] Error: --signal-only-exit requires --stdin-prompt-stream") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream --signal-only-exit") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && outputFormat !== "stream-json") { + console.error("[CLI] Error: --stdin-prompt-stream requires --output-format=stream-json") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && process.stdin.isTTY) { + console.error("[CLI] Error: --stdin-prompt-stream requires piped stdin") + console.error( + '[CLI] Example: printf \'{"command":"start","requestId":"1","prompt":"1+1=?"}\\n\' | roo --print --output-format stream-json --stdin-prompt-stream [options]', + ) + process.exit(1) + } + + if (flagOptions.stdinPromptStream && prompt) { + console.error("[CLI] Error: cannot use positional prompt or --prompt-file with --stdin-prompt-stream") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && requestedCreateSessionId) { + console.error("[CLI] Error: --create-with-session-id is not supported with --stdin-prompt-stream") + console.error('[CLI] Use per-request "taskId" in stdin start commands instead.') + process.exit(1) + } + + const useStdinPromptStream = flagOptions.stdinPromptStream + let resolvedResumeSessionId: string | undefined + + if (isResumeRequested) { + const workspaceSessions = await readWorkspaceTaskSessions(effectiveWorkspacePath) + try { + resolvedResumeSessionId = resolveWorkspaceResumeSessionId(workspaceSessions, requestedSessionId) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[CLI] Error: ${message}`) + process.exit(1) + } + } + + if (!isTuiEnabled) { + if (!prompt && !useStdinPromptStream && !isResumeRequested) { + if (flagOptions.print) { + console.error("[CLI] Error: no prompt provided") + console.error("[CLI] Usage: roo --print [options] ") + console.error( + "[CLI] For stdin control mode: roo --print --output-format stream-json --stdin-prompt-stream [options]", + ) + } else { + console.error("[CLI] Error: prompt is required in non-interactive mode") + console.error("[CLI] Usage: roo [options]") + console.error("[CLI] Run without -p for interactive mode") + } + + process.exit(1) + } + + if (!flagOptions.print) { + console.warn("[CLI] TUI disabled (no TTY support), falling back to print mode") + } + } + + // Run! + + if (isTuiEnabled) { + try { + const { render } = await import("ink") + const { App } = await import("../../ui/App.js") + + render( + createElement(App, { + ...extensionHostOptions, + initialPrompt: prompt, + initialTaskId: requestedCreateSessionId, + initialSessionId: resolvedResumeSessionId, + continueSession: false, + version: VERSION, + createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts), + }), + // Handle Ctrl+C in App component for double-press exit. + { exitOnCtrlC: false }, + ) + } catch (error) { + console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error)) + + if (error instanceof Error) { + console.error(error.stack) + } + + process.exit(1) + } + } else { + const useJsonOutput = outputFormat === "json" || outputFormat === "stream-json" + const signalOnlyExit = flagOptions.signalOnlyExit + + extensionHostOptions.disableOutput = useJsonOutput + + const host = new ExtensionHost(extensionHostOptions) + let streamRequestId: string | undefined + let keepAliveInterval: NodeJS.Timeout | undefined + let isShuttingDown = false + let hostDisposed = false + + const jsonEmitter = useJsonOutput + ? new JsonEventEmitter({ + mode: outputFormat as "json" | "stream-json", + requestIdProvider: () => streamRequestId, + }) + : null + + const emitRuntimeError = (error: Error, source?: string) => { + const errorMessage = source ? `${source}: ${error.message}` : error.message + + if (useJsonOutput) { + const errorEvent = { type: "error", id: Date.now(), content: errorMessage } + process.stdout.write(JSON.stringify(errorEvent) + "\n") + return + } + + console.error("[CLI] Error:", errorMessage) + console.error(error.stack) + } + + const clearKeepAliveInterval = () => { + if (!keepAliveInterval) { + return + } + + clearInterval(keepAliveInterval) + keepAliveInterval = undefined + } + + const flushStdout = async () => { + try { + if (!process.stdout.writable || process.stdout.destroyed) { + return + } + + await new Promise((resolve, reject) => { + process.stdout.write("", (error?: Error | null) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + } catch { + // Best effort: shutdown should proceed even if stdout flush fails. + } + } + + const ensureKeepAliveInterval = () => { + if (!signalOnlyExit || keepAliveInterval) { + return + } + + keepAliveInterval = setInterval(() => {}, SIGNAL_ONLY_EXIT_KEEPALIVE_MS) + } + + const disposeHost = async () => { + if (hostDisposed) { + return + } + + hostDisposed = true + jsonEmitter?.detach() + await host.dispose() + } + + const onSigint = () => { + void shutdown("SIGINT", 130) + } + + const onSigterm = () => { + void shutdown("SIGTERM", 143) + } + + const onUncaughtException = (error: Error) => { + if ( + isExpectedControlFlowError(error, { + stdinStreamMode: useStdinPromptStream, + shuttingDown: isShuttingDown, + operation: "runtime", + }) + ) { + return + } + + emitRuntimeError(error, "uncaughtException") + + if (signalOnlyExit) { + return + } + + void shutdown("uncaughtException", 1) + } + + const onUnhandledRejection = (reason: unknown) => { + if ( + isExpectedControlFlowError(reason, { + stdinStreamMode: useStdinPromptStream, + shuttingDown: isShuttingDown, + operation: "runtime", + }) + ) { + return + } + + const error = normalizeError(reason) + emitRuntimeError(error, "unhandledRejection") + + if (signalOnlyExit) { + return + } + + void shutdown("unhandledRejection", 1) + } + + const parkUntilSignal = async (reason: string): Promise => { + ensureKeepAliveInterval() + + if (!useJsonOutput) { + console.error(`[CLI] ${reason} (--signal-only-exit active; waiting for SIGINT/SIGTERM).`) + } + + await new Promise(() => {}) + throw new Error("unreachable") + } + + async function shutdown(signal: string, exitCode: number): Promise { + if (isShuttingDown) { + return + } + + isShuttingDown = true + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + process.off("uncaughtException", onUncaughtException) + process.off("unhandledRejection", onUnhandledRejection) + clearKeepAliveInterval() + + if (!useJsonOutput) { + console.log(`\n[CLI] Received ${signal}, shutting down...`) + } + + await disposeHost() + if (jsonEmitter) { + await jsonEmitter.flush() + } + await flushStdout() + process.exit(exitCode) + } + + process.on("SIGINT", onSigint) + process.on("SIGTERM", onSigterm) + process.on("uncaughtException", onUncaughtException) + process.on("unhandledRejection", onUnhandledRejection) + + try { + await host.activate() + + if (jsonEmitter) { + jsonEmitter.attachToClient(host.client) + } + + if (useStdinPromptStream) { + if (!jsonEmitter || outputFormat !== "stream-json") { + throw new Error("--stdin-prompt-stream requires --output-format=stream-json to emit control events") + } + + if (isResumeRequested) { + await bootstrapResumeForStdinStream(host, resolvedResumeSessionId!) + } + + await runStdinStreamMode({ + host, + jsonEmitter, + setStreamRequestId: (id) => { + streamRequestId = id + }, + }) + } else { + if (isResumeRequested) { + await host.resumeTask(resolvedResumeSessionId!) + } else { + await host.runTask(prompt!, requestedCreateSessionId) + } + } + + await disposeHost() + if (jsonEmitter) { + await jsonEmitter.flush() + } + await flushStdout() + + if (signalOnlyExit) { + await parkUntilSignal("Task loop completed") + } + + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + process.off("uncaughtException", onUncaughtException) + process.off("unhandledRejection", onUnhandledRejection) + process.exit(0) + } catch (error) { + emitRuntimeError(normalizeError(error)) + await disposeHost() + if (jsonEmitter) { + await jsonEmitter.flush() + } + await flushStdout() + + if (signalOnlyExit) { + await parkUntilSignal("Task loop failed") + } + + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + process.off("uncaughtException", onUncaughtException) + process.off("unhandledRejection", onUnhandledRejection) + process.exit(1) + } + } +} diff --git a/apps/cli/src/commands/cli/stdin-stream.ts b/apps/cli/src/commands/cli/stdin-stream.ts new file mode 100644 index 00000000000..a9e4c474583 --- /dev/null +++ b/apps/cli/src/commands/cli/stdin-stream.ts @@ -0,0 +1,977 @@ +import { createInterface } from "readline" +import { randomUUID } from "crypto" + +import { + rooCliCommandNames, + type RooCliCommandName, + type RooCliInputCommand, + type RooCliStartCommand, +} from "@roo-code/types" + +import { isRecord } from "@/lib/utils/guards.js" +import { isValidSessionId } from "@/lib/utils/session-id.js" +import { isCancellationLikeError, isExpectedControlFlowError, isNoActiveTaskLikeError } from "./cancellation.js" + +import type { ExtensionHost } from "@/agent/index.js" +import type { JsonEventEmitter } from "@/agent/json-event-emitter.js" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type StdinStreamCommandName = RooCliCommandName + +export type StdinStreamCommand = RooCliInputCommand + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +export const VALID_STDIN_COMMANDS = new Set(rooCliCommandNames) + +export function parseStdinStreamCommand(line: string, lineNumber: number): StdinStreamCommand { + let parsed: unknown + + try { + parsed = JSON.parse(line) + } catch { + throw new Error(`stdin command line ${lineNumber}: invalid JSON`) + } + + if (!isRecord(parsed)) { + throw new Error(`stdin command line ${lineNumber}: expected JSON object`) + } + + const commandRaw = parsed.command + const requestIdRaw = parsed.requestId + + if (typeof commandRaw !== "string") { + throw new Error(`stdin command line ${lineNumber}: missing string "command"`) + } + + if (!VALID_STDIN_COMMANDS.has(commandRaw as StdinStreamCommandName)) { + throw new Error( + `stdin command line ${lineNumber}: unsupported command "${commandRaw}" (expected start|message|cancel|ping|shutdown)`, + ) + } + + if (typeof requestIdRaw !== "string" || requestIdRaw.trim().length === 0) { + throw new Error(`stdin command line ${lineNumber}: missing non-empty string "requestId"`) + } + + const command = commandRaw as StdinStreamCommandName + const requestId = requestIdRaw.trim() + + if (command === "start" || command === "message") { + const promptRaw = parsed.prompt + + if (typeof promptRaw !== "string" || promptRaw.trim().length === 0) { + throw new Error(`stdin command line ${lineNumber}: "${command}" requires non-empty string "prompt"`) + } + + const imagesRaw = parsed.images + let images: string[] | undefined + + if (imagesRaw !== undefined) { + if (!Array.isArray(imagesRaw) || !imagesRaw.every((image) => typeof image === "string")) { + throw new Error(`stdin command line ${lineNumber}: "${command}" images must be an array of strings`) + } + + images = imagesRaw + } + + if (command === "start") { + const taskIdRaw = parsed.taskId + let taskId: string | undefined + + if (taskIdRaw !== undefined) { + if (typeof taskIdRaw !== "string" || taskIdRaw.trim().length === 0) { + throw new Error(`stdin command line ${lineNumber}: "start" taskId must be a non-empty string`) + } + taskId = taskIdRaw.trim() + + if (!isValidSessionId(taskId)) { + throw new Error(`stdin command line ${lineNumber}: "start" taskId must be a valid UUID`) + } + } + + if (isRecord(parsed.configuration)) { + return { + command, + requestId, + prompt: promptRaw, + ...(taskId !== undefined ? { taskId } : {}), + ...(images !== undefined ? { images } : {}), + configuration: parsed.configuration as RooCliStartCommand["configuration"], + } + } + + return { + command, + requestId, + prompt: promptRaw, + ...(taskId !== undefined ? { taskId } : {}), + ...(images !== undefined ? { images } : {}), + } + } + + return { + command, + requestId, + prompt: promptRaw, + ...(images !== undefined ? { images } : {}), + } + } + + return { command, requestId } +} + +// --------------------------------------------------------------------------- +// NDJSON stdin reader +// --------------------------------------------------------------------------- + +async function* readCommandsFromStdinNdjson(): AsyncGenerator { + const lineReader = createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, + }) + + let lineNumber = 0 + + try { + for await (const line of lineReader) { + lineNumber += 1 + const trimmed = line.trim() + if (!trimmed) { + continue + } + yield parseStdinStreamCommand(trimmed, lineNumber) + } + } finally { + lineReader.close() + } +} + +// --------------------------------------------------------------------------- +// Queue snapshot helpers +// --------------------------------------------------------------------------- + +interface StreamQueueItem { + id: string + text?: string + imageCount: number + timestamp?: number +} + +function normalizeQueueText(text: string | undefined): string | undefined { + if (!text) { + return undefined + } + + const compact = text.replace(/\s+/g, " ").trim() + if (!compact) { + return undefined + } + + return compact.length <= 180 ? compact : `${compact.slice(0, 177)}...` +} + +function parseQueueSnapshot(rawQueue: unknown): StreamQueueItem[] | undefined { + if (!Array.isArray(rawQueue)) { + return undefined + } + + const snapshot: StreamQueueItem[] = [] + + for (const entry of rawQueue) { + if (!isRecord(entry)) { + continue + } + + const idRaw = entry.id + if (typeof idRaw !== "string" || idRaw.trim().length === 0) { + continue + } + + const imagesRaw = entry.images + const timestampRaw = entry.timestamp + const imageCount = Array.isArray(imagesRaw) ? imagesRaw.length : 0 + + snapshot.push({ + id: idRaw, + text: normalizeQueueText(typeof entry.text === "string" ? entry.text : undefined), + imageCount, + timestamp: typeof timestampRaw === "number" ? timestampRaw : undefined, + }) + } + + return snapshot +} + +function areStringArraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false + } + } + + return true +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +export interface StdinStreamModeOptions { + host: ExtensionHost + jsonEmitter: JsonEventEmitter + setStreamRequestId: (id: string | undefined) => void +} + +const RESUME_ASKS = new Set(["resume_task", "resume_completed_task"]) +const CANCEL_RECOVERY_WAIT_TIMEOUT_MS = 8_000 +const CANCEL_RECOVERY_POLL_INTERVAL_MS = 100 +const STDIN_EOF_RESUME_WAIT_TIMEOUT_MS = 2_000 +const STDIN_EOF_POLL_INTERVAL_MS = 100 +const STDIN_EOF_IDLE_ASKS = new Set(["completion_result", "resume_completed_task"]) +const STDIN_EOF_IDLE_STABLE_POLLS = 2 +const MESSAGE_AS_ASK_RESPONSE_ASKS = new Set([ + "followup", + "tool", + "command", + "use_mcp_server", + "completion_result", + "resume_task", + "resume_completed_task", + "mistake_limit_reached", +]) + +export function shouldSendMessageAsAskResponse(waitingForInput: boolean, currentAsk: string | undefined): boolean { + return waitingForInput && typeof currentAsk === "string" && MESSAGE_AS_ASK_RESPONSE_ASKS.has(currentAsk) +} + +function isResumableState(host: ExtensionHost): boolean { + const agentState = host.client.getAgentState() + return ( + agentState.isWaitingForInput && + typeof agentState.currentAsk === "string" && + RESUME_ASKS.has(agentState.currentAsk) + ) +} + +async function waitForPostCancelRecovery(host: ExtensionHost): Promise { + const deadline = Date.now() + CANCEL_RECOVERY_WAIT_TIMEOUT_MS + + while (Date.now() < deadline) { + if (isResumableState(host)) { + return + } + + await new Promise((resolve) => setTimeout(resolve, CANCEL_RECOVERY_POLL_INTERVAL_MS)) + } +} + +async function waitForTaskProgressAfterStdinClosed( + host: ExtensionHost, + getQueueState: () => { hasSeenQueueState: boolean; queueDepth: number }, +): Promise { + while (host.client.hasActiveTask()) { + if (!host.isWaitingForInput()) { + await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS)) + continue + } + + const deadline = Date.now() + STDIN_EOF_RESUME_WAIT_TIMEOUT_MS + + while (Date.now() < deadline) { + if (!host.client.hasActiveTask() || !host.isWaitingForInput()) { + break + } + + await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS)) + } + + if (host.client.hasActiveTask() && host.isWaitingForInput()) { + const currentAsk = host.client.getCurrentAsk() + const { hasSeenQueueState, queueDepth } = getQueueState() + + // EOF is allowed when the task has reached an idle completion boundary and + // there is no queued user input waiting to be processed. + if ( + hasSeenQueueState && + queueDepth === 0 && + typeof currentAsk === "string" && + STDIN_EOF_IDLE_ASKS.has(currentAsk) + ) { + let isStable = true + for (let i = 1; i < STDIN_EOF_IDLE_STABLE_POLLS; i++) { + await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS)) + + if (!host.client.hasActiveTask() || !host.isWaitingForInput()) { + isStable = false + break + } + + const nextAsk = host.client.getCurrentAsk() + const nextQueueState = getQueueState() + if ( + nextAsk !== currentAsk || + !nextQueueState.hasSeenQueueState || + nextQueueState.queueDepth !== 0 + ) { + isStable = false + break + } + } + + if (isStable) { + return + } + } + + throw new Error(`stdin ended while task was waiting for input (${currentAsk ?? "unknown"})`) + } + } +} + +export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId }: StdinStreamModeOptions) { + let hasReceivedStdinCommand = false + let shouldShutdown = false + let activeTaskPromise: Promise | null = null + let fatalStreamError: Error | null = null + let activeRequestId: string | undefined + let activeTaskCommand: "start" | undefined + let latestTaskId: string | undefined + let cancelRequestedForActiveTask = false + let awaitingPostCancelRecovery = false + let hasSeenQueueState = false + let lastQueueDepth = 0 + let lastQueueMessageIds: string[] = [] + const pendingQueuedMessageRequestIds: string[] = [] + const queueMessageRequestIdByMessageId = new Map() + + const assignRequestIdsToNewQueueMessages = (queueMessageIds: string[]) => { + for (const messageId of queueMessageIds) { + if (queueMessageRequestIdByMessageId.has(messageId)) { + continue + } + + const requestId = pendingQueuedMessageRequestIds.shift() + if (!requestId) { + continue + } + + queueMessageRequestIdByMessageId.set(messageId, requestId) + } + } + + const promoteRequestIdForDequeuedMessages = (queueMessageIds: string[]) => { + if (lastQueueMessageIds.length === 0) { + return + } + + const remainingIds = new Set(queueMessageIds) + + for (const dequeuedMessageId of lastQueueMessageIds) { + if (remainingIds.has(dequeuedMessageId)) { + continue + } + + const requestId = queueMessageRequestIdByMessageId.get(dequeuedMessageId) + if (requestId) { + setStreamRequestId(requestId) + } + queueMessageRequestIdByMessageId.delete(dequeuedMessageId) + } + } + + const waitForPreviousTaskToSettle = async () => { + if (!activeTaskPromise) { + return + } + + try { + await activeTaskPromise + } catch { + // Errors are emitted through control/error events. + } + } + + const offClientError = host.client.on("error", (error) => { + if ( + isExpectedControlFlowError(error, { + stdinStreamMode: true, + cancelRequested: cancelRequestedForActiveTask, + shuttingDown: shouldShutdown, + operation: "client", + }) + ) { + if (activeTaskCommand === "start" && (cancelRequestedForActiveTask || isCancellationLikeError(error))) { + jsonEmitter.emitControl({ + subtype: "done", + requestId: activeRequestId, + command: "start", + taskId: latestTaskId, + content: "task cancelled", + code: "task_aborted", + success: false, + }) + } + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + cancelRequestedForActiveTask = false + awaitingPostCancelRecovery = false + return + } + + fatalStreamError = error + jsonEmitter.emitControl({ + subtype: "error", + requestId: activeRequestId, + command: activeTaskCommand, + taskId: latestTaskId, + content: error.message, + code: "client_error", + success: false, + }) + }) + + const onExtensionMessage = (message: { + type?: string + text?: unknown + state?: { + currentTaskId?: unknown + currentTaskItem?: { id?: unknown } + messageQueue?: unknown + } + }) => { + if (message.type === "commandExecutionStatus") { + if (typeof message.text !== "string") { + return + } + + let parsedStatus: unknown + try { + parsedStatus = JSON.parse(message.text) + } catch { + return + } + + if (!isRecord(parsedStatus) || typeof parsedStatus.status !== "string") { + return + } + + if (parsedStatus.status === "output" && typeof parsedStatus.output === "string") { + jsonEmitter.emitCommandOutputChunk(parsedStatus.output) + return + } + + if (parsedStatus.status === "exited") { + const exitCode = + parsedStatus.status === "exited" && typeof parsedStatus.exitCode === "number" + ? parsedStatus.exitCode + : undefined + + if (typeof parsedStatus.output === "string") { + jsonEmitter.emitCommandOutputChunk(parsedStatus.output) + } + + jsonEmitter.markCommandOutputExited(exitCode) + return + } + + if (parsedStatus.status === "timeout" || parsedStatus.status === "fallback") { + jsonEmitter.emitCommandOutputDone(undefined) + return + } + + return + } + + if (message.type !== "state") { + return + } + + const currentTaskId = message.state?.currentTaskId ?? message.state?.currentTaskItem?.id + if (typeof currentTaskId === "string" && currentTaskId.trim().length > 0) { + latestTaskId = currentTaskId + } + + const queueSnapshot = parseQueueSnapshot(message.state?.messageQueue) + if (!queueSnapshot) { + return + } + + const queueDepth = queueSnapshot.length + const queueMessageIds = queueSnapshot.map((item) => item.id) + + if (!hasSeenQueueState) { + assignRequestIdsToNewQueueMessages(queueMessageIds) + hasSeenQueueState = true + lastQueueDepth = queueDepth + lastQueueMessageIds = queueMessageIds + + if (queueDepth === 0) { + return + } + + jsonEmitter.emitQueue({ + subtype: "snapshot", + taskId: latestTaskId, + content: `queue snapshot (${queueDepth} item${queueDepth === 1 ? "" : "s"})`, + queueDepth, + queue: queueSnapshot, + }) + return + } + + const depthChanged = queueDepth !== lastQueueDepth + const idsChanged = !areStringArraysEqual(queueMessageIds, lastQueueMessageIds) + + if (!depthChanged && !idsChanged) { + return + } + + promoteRequestIdForDequeuedMessages(queueMessageIds) + assignRequestIdsToNewQueueMessages(queueMessageIds) + + const subtype: "enqueued" | "dequeued" | "drained" | "updated" = depthChanged + ? queueDepth > lastQueueDepth + ? "enqueued" + : queueDepth === 0 + ? "drained" + : "dequeued" + : "updated" + + const content = + subtype === "drained" + ? "queue drained" + : `queue ${subtype} (${queueDepth} item${queueDepth === 1 ? "" : "s"})` + + jsonEmitter.emitQueue({ + subtype, + taskId: latestTaskId, + content, + queueDepth, + queue: queueSnapshot, + }) + + lastQueueDepth = queueDepth + lastQueueMessageIds = queueMessageIds + } + + host.on("extensionWebviewMessage", onExtensionMessage) + + const offTaskCompleted = host.client.on("taskCompleted", (event) => { + if (activeTaskCommand === "start") { + const completionCode = event.success + ? "task_completed" + : cancelRequestedForActiveTask + ? "task_aborted" + : "task_failed" + + jsonEmitter.emitControl({ + subtype: "done", + requestId: activeRequestId, + command: "start", + taskId: latestTaskId, + content: event.success + ? "task completed" + : cancelRequestedForActiveTask + ? "task cancelled" + : "task failed", + code: completionCode, + success: event.success, + }) + + // If user messages were queued while the task was still running, shift + // event attribution to the oldest pending message request as soon as the + // task turn completes so prompt echo/user feedback events are tagged. + const oldestQueuedMessageId = lastQueueMessageIds[0] + const nextQueuedRequestId = + pendingQueuedMessageRequestIds[0] ?? + (oldestQueuedMessageId ? queueMessageRequestIdByMessageId.get(oldestQueuedMessageId) : undefined) + if (nextQueuedRequestId) { + setStreamRequestId(nextQueuedRequestId) + } + + activeTaskCommand = undefined + activeRequestId = undefined + cancelRequestedForActiveTask = false + } + }) + + try { + for await (const stdinCommand of readCommandsFromStdinNdjson()) { + hasReceivedStdinCommand = true + + if (fatalStreamError) { + throw fatalStreamError + } + + switch (stdinCommand.command) { + case "start": { + // A task can emit completion events before runTask() finalizers run. + // Wait for full settlement to avoid false "task_busy" on immediate next start. + // Safe from races: `for await` processes stdin commands serially, so no + // concurrent command can mutate state between the check and the await. + if (activeTaskPromise && !host.client.hasActiveTask()) { + await waitForPreviousTaskToSettle() + } + + if (activeTaskPromise || host.client.hasActiveTask()) { + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "cannot start a new task while another task is active", + code: "task_busy", + success: false, + }) + + break + } + + activeRequestId = stdinCommand.requestId + activeTaskCommand = "start" + setStreamRequestId(stdinCommand.requestId) + latestTaskId = stdinCommand.taskId ?? randomUUID() + cancelRequestedForActiveTask = false + awaitingPostCancelRecovery = false + + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "starting task", + code: "accepted", + success: true, + }) + + // In CLI stdin-stream mode, default to the execa terminal provider so + // command output can be streamed deterministically. Explicit per-request + // config still wins. + const taskConfiguration = { + terminalShellIntegrationDisabled: true, + ...(stdinCommand.configuration ?? {}), + } + + activeTaskPromise = host + .runTask(stdinCommand.prompt, latestTaskId, taskConfiguration, stdinCommand.images) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + + if ( + isExpectedControlFlowError(error, { + stdinStreamMode: true, + cancelRequested: cancelRequestedForActiveTask, + shuttingDown: shouldShutdown, + operation: "client", + }) + ) { + if ( + activeTaskCommand === "start" && + (cancelRequestedForActiveTask || isCancellationLikeError(error)) + ) { + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "task cancelled", + code: "task_aborted", + success: false, + }) + } + + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + cancelRequestedForActiveTask = false + awaitingPostCancelRecovery = false + return + } + + fatalStreamError = error instanceof Error ? error : new Error(message) + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: message, + code: "task_error", + success: false, + }) + }) + .finally(() => { + activeTaskPromise = null + }) + + break + } + + case "message": { + // If cancel was requested, wait briefly for the task to be rehydrated + // so message prompts don't race into the pre-cancel task instance. + if (awaitingPostCancelRecovery) { + await waitForPostCancelRecovery(host) + } + + const wasResumable = isResumableState(host) + const currentAsk = host.client.getCurrentAsk() + const shouldSendAsAskResponse = shouldSendMessageAsAskResponse(host.isWaitingForInput(), currentAsk) + + if (!host.client.hasActiveTask()) { + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "no active task; send a start command first", + code: "no_active_task", + success: false, + }) + + break + } + + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "message accepted", + code: "accepted", + success: true, + }) + + if (shouldSendAsAskResponse) { + // Match webview behavior: if there is an active ask, route message directly as an ask response. + host.sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text: stdinCommand.prompt, + images: stdinCommand.images, + }) + + setStreamRequestId(stdinCommand.requestId) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "message sent to current ask", + code: "responded", + success: true, + }) + awaitingPostCancelRecovery = false + break + } + + host.sendToExtension({ + type: "queueMessage", + text: stdinCommand.prompt, + images: stdinCommand.images, + }) + pendingQueuedMessageRequestIds.push(stdinCommand.requestId) + if (host.isWaitingForInput()) { + setStreamRequestId(stdinCommand.requestId) + } + + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: wasResumable ? "resume message queued" : "message queued", + code: wasResumable ? "resumed" : "queued", + success: true, + }) + + awaitingPostCancelRecovery = false + break + } + + case "cancel": { + setStreamRequestId(stdinCommand.requestId) + + const hasTaskInFlight = Boolean( + activeTaskPromise || activeTaskCommand === "start" || host.client.hasActiveTask(), + ) + + if (!hasTaskInFlight) { + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "no active task to cancel", + code: "accepted", + success: true, + }) + + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "cancel ignored (no active task)", + code: "no_active_task", + success: true, + }) + + break + } + + cancelRequestedForActiveTask = true + awaitingPostCancelRecovery = true + + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: host.client.hasActiveTask() ? "cancel requested" : "cancel requested (task starting)", + code: "accepted", + success: true, + }) + + try { + host.client.cancelTask() + + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "cancel signal sent", + code: "cancel_requested", + success: true, + }) + } catch (error) { + if ( + isExpectedControlFlowError(error, { + stdinStreamMode: true, + cancelRequested: true, + shuttingDown: shouldShutdown, + operation: "cancel", + }) + ) { + const noActiveTask = isNoActiveTaskLikeError(error) + + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: noActiveTask ? "cancel ignored (task already settled)" : "cancel handled", + code: noActiveTask ? "no_active_task" : "cancel_requested", + success: true, + }) + + if (noActiveTask) { + awaitingPostCancelRecovery = false + } + + cancelRequestedForActiveTask = false + } else { + const message = error instanceof Error ? error.message : String(error) + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: message, + code: "cancel_error", + success: false, + }) + } + } + break + } + + case "ping": + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "ping", + taskId: latestTaskId, + content: "pong", + code: "accepted", + success: true, + }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "ping", + taskId: latestTaskId, + content: "pong", + code: "pong", + success: true, + }) + break + + case "shutdown": + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "shutdown", + taskId: latestTaskId, + content: "shutdown requested", + code: "accepted", + success: true, + }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "shutdown", + taskId: latestTaskId, + content: "shutting down process", + code: "shutdown_requested", + success: true, + }) + shouldShutdown = true + break + } + + if (shouldShutdown) { + break + } + } + + if (!hasReceivedStdinCommand) { + throw new Error("no stdin command provided") + } + + if (shouldShutdown && host.client.hasActiveTask()) { + host.client.cancelTask() + } + + if (!shouldShutdown) { + if (activeTaskPromise) { + await activeTaskPromise + } else if (host.client.hasActiveTask()) { + await waitForTaskProgressAfterStdinClosed(host, () => ({ + hasSeenQueueState, + queueDepth: lastQueueDepth, + })) + } + } + } finally { + offClientError() + host.off("extensionWebviewMessage", onExtensionMessage) + offTaskCompleted() + } +} diff --git a/apps/cli/src/commands/cli/upgrade.ts b/apps/cli/src/commands/cli/upgrade.ts new file mode 100644 index 00000000000..a3ff4ee94bd --- /dev/null +++ b/apps/cli/src/commands/cli/upgrade.ts @@ -0,0 +1,155 @@ +import { spawn } from "child_process" + +import { VERSION } from "@/lib/utils/version.js" +import { isRecord } from "@/lib/utils/guards.js" + +const RELEASES_URL = "https://api.github.com/repos/RooCodeInc/Roo-Code/releases?per_page=100" +export const INSTALL_SCRIPT_COMMAND = + "curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" + +export interface UpgradeOptions { + currentVersion?: string + fetchImpl?: typeof fetch + runInstaller?: () => Promise +} + +function parseVersion(version: string): number[] { + const cleaned = version + .trim() + .replace(/^cli-v/, "") + .replace(/^v/, "") + const core = cleaned.split("+", 1)[0]?.split("-", 1)[0] + + if (!core) { + throw new Error(`Invalid version: ${version}`) + } + + const parts = core.split(".") + if (parts.length === 0) { + throw new Error(`Invalid version: ${version}`) + } + + return parts.map((part) => { + if (!/^\d+$/.test(part)) { + throw new Error(`Invalid version: ${version}`) + } + + return Number.parseInt(part, 10) + }) +} + +/** + * Returns: + * - 1 when `a > b` + * - 0 when `a === b` + * - -1 when `a < b` + */ +export function compareVersions(a: string, b: string): number { + const aParts = parseVersion(a) + const bParts = parseVersion(b) + const maxLength = Math.max(aParts.length, bParts.length) + + for (let i = 0; i < maxLength; i++) { + const aPart = aParts[i] ?? 0 + const bPart = bParts[i] ?? 0 + + if (aPart > bPart) { + return 1 + } + + if (aPart < bPart) { + return -1 + } + } + + return 0 +} + +export async function getLatestCliVersion(fetchImpl: typeof fetch = fetch): Promise { + const response = await fetchImpl(RELEASES_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "roo-cli", + }, + }) + + if (!response.ok) { + throw new Error(`Failed to check latest version (HTTP ${response.status})`) + } + + const releases = await response.json() + if (!Array.isArray(releases)) { + throw new Error("Invalid release response from GitHub.") + } + + let latestVersion: string | undefined + + for (const release of releases) { + if (!isRecord(release)) { + continue + } + + const tagName = release.tag_name + if (typeof tagName === "string" && tagName.startsWith("cli-v")) { + const candidate = tagName.slice("cli-v".length) + try { + if (!latestVersion || compareVersions(candidate, latestVersion) > 0) { + latestVersion = candidate + } + } catch { + // Ignore malformed CLI tags and keep scanning other releases. + } + } + } + + if (latestVersion) { + return latestVersion + } + + throw new Error("Could not determine the latest CLI release version.") +} + +export function runUpgradeInstaller(version?: string, spawnImpl: typeof spawn = spawn): Promise { + return new Promise((resolve, reject) => { + const env = version ? { ...process.env, ROO_VERSION: version } : process.env + const child = spawnImpl("sh", ["-c", INSTALL_SCRIPT_COMMAND], { stdio: "inherit", env }) + + child.once("error", (error) => { + reject(error) + }) + + child.once("close", (code, signal) => { + if (code === 0) { + resolve() + return + } + + const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}` + reject(new Error(`Upgrade installer failed (${reason}).`)) + }) + }) +} + +export async function upgrade(options: UpgradeOptions = {}): Promise { + const currentVersion = options.currentVersion ?? VERSION + const fetchImpl = options.fetchImpl ?? fetch + const runInstaller = options.runInstaller + + console.log(`Current version: ${currentVersion}`) + + const latestVersion = await getLatestCliVersion(fetchImpl) + console.log(`Latest version: ${latestVersion}`) + + if (compareVersions(latestVersion, currentVersion) <= 0) { + console.log("Roo CLI is already up to date.") + return + } + + console.log(`Upgrading Roo CLI from ${currentVersion} to ${latestVersion}...`) + if (runInstaller) { + await runInstaller() + } else { + await runUpgradeInstaller(latestVersion) + } + console.log("✓ Upgrade completed.") +} diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts new file mode 100644 index 00000000000..54fb955464d --- /dev/null +++ b/apps/cli/src/commands/index.ts @@ -0,0 +1 @@ +export * from "./cli/index.js" diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 00000000000..79a4a724751 --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,129 @@ +import { Command } from "commander" + +import { DEFAULT_FLAGS } from "@/types/constants.js" +import { VERSION } from "@/lib/utils/version.js" +import { run, listCommands, listModes, listModels, listSessions, upgrade } from "@/commands/index.js" + +const program = new Command() + +program + .name("roo") + .description("Roo Code CLI - starts an interactive session by default, use -p/--print for non-interactive output") + .version(VERSION) + .enablePositionalOptions() + .passThroughOptions() + +program + .argument("[prompt]", "Your prompt") + .option("--prompt-file ", "Read prompt from a file instead of command line argument") + .option("--create-with-session-id ", "Create a new task with a specific session ID (must be a UUID)") + .option("--session-id ", "Resume a specific task by session ID") + .option("-c, --continue", "Resume the most recent task in the current workspace", false) + .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") + .option("-p, --print", "Print response and exit (non-interactive mode)", false) + .option( + "--stdin-prompt-stream", + "Read NDJSON commands from stdin (requires --print and --output-format stream-json)", + false, + ) + .option( + "--signal-only-exit", + "Do not exit from normal completion/errors; only terminate on SIGINT/SIGTERM (intended for stdin stream harnesses)", + false, + ) + .option("-e, --extension ", "Path to the extension bundle directory") + .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) + .option("-a, --require-approval", "Require manual approval for actions", false) + .option("-k, --api-key ", "API key for the LLM provider") + .option("--provider ", "API provider (anthropic, openai, openrouter, etc.)") + .option("-m, --model ", "Model to use", DEFAULT_FLAGS.model) + .option("--mode ", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode) + .option("--terminal-shell ", "Absolute path to shell executable for inline terminal commands") + .option( + "-r, --reasoning-effort ", + "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", + DEFAULT_FLAGS.reasoningEffort, + ) + .option( + "--consecutive-mistake-limit ", + "Consecutive error/repetition limit before guidance prompt (0 disables the limit)", + (value) => Number.parseInt(value, 10), + ) + .option("--exit-on-error", "Exit on API request errors instead of retrying", false) + .option("--ephemeral", "Run without persisting state (uses temporary storage)", false) + .option("--oneshot", "Exit upon task completion", false) + .option( + "--output-format ", + 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', + "text", + ) + .action(run) + +const listCommand = program + .command("list") + .description("List commands, modes, models, or sessions") + .enablePositionalOptions() + .passThroughOptions() + +const applyListOptions = (command: Command) => + command + .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") + .option("-e, --extension ", "Path to the extension bundle directory") + .option("-k, --api-key ", "API key for the LLM provider") + .option("--format ", 'Output format: "json" (default) or "text"', "json") + .option("-d, --debug", "Enable debug output", false) + +const runListAction = async (action: () => Promise) => { + try { + await action() + process.exit(0) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[CLI] Error: ${message}`) + process.exit(1) + } +} + +const runUpgradeAction = async (action: () => Promise) => { + try { + await action() + process.exit(0) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[CLI] Error: ${message}`) + process.exit(1) + } +} + +applyListOptions(listCommand.command("commands").description("List available slash commands")).action( + async (options: Parameters[0]) => { + await runListAction(() => listCommands(options)) + }, +) + +applyListOptions(listCommand.command("modes").description("List available modes")).action( + async (options: Parameters[0]) => { + await runListAction(() => listModes(options)) + }, +) + +applyListOptions(listCommand.command("models").description("List available models")).action( + async (options: Parameters[0]) => { + await runListAction(() => listModels(options)) + }, +) + +applyListOptions(listCommand.command("sessions").description("List task sessions")).action( + async (options: Parameters[0]) => { + await runListAction(() => listSessions(options)) + }, +) + +program + .command("upgrade") + .description("Upgrade Roo Code CLI to the latest version") + .action(async () => { + await runUpgradeAction(() => upgrade()) + }) + +program.parse() diff --git a/apps/cli/src/lib/sdk/client.ts b/apps/cli/src/lib/sdk/client.ts new file mode 100644 index 00000000000..ff60e798ef6 --- /dev/null +++ b/apps/cli/src/lib/sdk/client.ts @@ -0,0 +1,30 @@ +import { createTRPCProxyClient, httpBatchLink } from "@trpc/client" +import superjson from "superjson" + +import type { User, Org } from "./types.js" + +export interface ClientConfig { + url: string + authToken: string +} + +export interface RooClient { + auth: { + me: { + query: () => Promise<{ type: "user"; user: User } | { type: "org"; org: Org } | null> + } + } +} + +export const createClient = ({ url, authToken }: ClientConfig): RooClient => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${url}/trpc`, + transformer: superjson, + headers: () => (authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }), + ], + }) as unknown as RooClient +} diff --git a/apps/cli/src/lib/sdk/index.ts b/apps/cli/src/lib/sdk/index.ts new file mode 100644 index 00000000000..f45970d7d12 --- /dev/null +++ b/apps/cli/src/lib/sdk/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js" +export * from "./client.js" diff --git a/apps/cli/src/lib/sdk/types.ts b/apps/cli/src/lib/sdk/types.ts new file mode 100644 index 00000000000..9f0511bb2ce --- /dev/null +++ b/apps/cli/src/lib/sdk/types.ts @@ -0,0 +1,31 @@ +export interface User { + id: string + name: string + email: string + imageUrl: string | null + entity: { + id: string + username: string | null + image_url: string + last_name: string + first_name: string + email_addresses: { email_address: string }[] + public_metadata: Record + } + publicMetadata: Record + stripeCustomerId: string | null + lastSyncAt: string + deletedAt: string | null + createdAt: string + updatedAt: string +} + +export interface Org { + id: string + name: string + slug: string + imageUrl: string | null + createdAt: string + updatedAt: string + deletedAt: string | null +} diff --git a/apps/cli/src/lib/storage/__tests__/history.test.ts b/apps/cli/src/lib/storage/__tests__/history.test.ts new file mode 100644 index 00000000000..f928c2fb426 --- /dev/null +++ b/apps/cli/src/lib/storage/__tests__/history.test.ts @@ -0,0 +1,240 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY_ENTRIES } from "../history.js" + +vi.mock("fs/promises") + +vi.mock("os", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + ...actual, + homedir: vi.fn(() => "/home/testuser"), + }, + homedir: vi.fn(() => "/home/testuser"), + } +}) + +describe("historyStorage", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe("getHistoryFilePath", () => { + it("should return the correct path to cli-history.json", () => { + const result = getHistoryFilePath() + expect(result).toBe(path.join("/home/testuser", ".roo", "cli-history.json")) + }) + }) + + describe("loadHistory", () => { + it("should return empty array when file does not exist", async () => { + const error = new Error("ENOENT") as NodeJS.ErrnoException + error.code = "ENOENT" + vi.mocked(fs.readFile).mockRejectedValue(error) + + const result = await loadHistory() + + expect(result).toEqual([]) + }) + + it("should return entries from valid JSON file", async () => { + const mockData = { + version: 1, + entries: ["first command", "second command", "third command"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await loadHistory() + + expect(result).toEqual(["first command", "second command", "third command"]) + }) + + it("should return empty array for invalid JSON", async () => { + vi.mocked(fs.readFile).mockResolvedValue("not valid json") + + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await loadHistory() + + expect(result).toEqual([]) + consoleSpy.mockRestore() + }) + + it("should filter out non-string entries", async () => { + const mockData = { + version: 1, + entries: ["valid", 123, "also valid", null, ""], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await loadHistory() + + expect(result).toEqual(["valid", "also valid"]) + }) + + it("should return empty array when entries is not an array", async () => { + const mockData = { + version: 1, + entries: "not an array", + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await loadHistory() + + expect(result).toEqual([]) + }) + }) + + describe("saveHistory", () => { + it("should create directory and save history", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + await saveHistory(["command1", "command2"]) + + expect(fs.mkdir).toHaveBeenCalledWith(path.join("/home/testuser", ".roo"), { recursive: true }) + expect(fs.writeFile).toHaveBeenCalled() + + // Verify the content written + const writeCall = vi.mocked(fs.writeFile).mock.calls[0] + const writtenContent = JSON.parse(writeCall?.[1] as string) + expect(writtenContent.version).toBe(1) + expect(writtenContent.entries).toEqual(["command1", "command2"]) + }) + + it("should trim entries to MAX_HISTORY_ENTRIES", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + // Create array larger than MAX_HISTORY_ENTRIES + const manyEntries = Array.from({ length: MAX_HISTORY_ENTRIES + 100 }, (_, i) => `command${i}`) + + await saveHistory(manyEntries) + + const writeCall = vi.mocked(fs.writeFile).mock.calls[0] + const writtenContent = JSON.parse(writeCall?.[1] as string) + expect(writtenContent.entries.length).toBe(MAX_HISTORY_ENTRIES) + // Should keep the most recent entries (last 500) + expect(writtenContent.entries[0]).toBe(`command100`) + expect(writtenContent.entries[MAX_HISTORY_ENTRIES - 1]).toBe(`command${MAX_HISTORY_ENTRIES + 99}`) + }) + + it("should handle directory already exists error", async () => { + const error = new Error("EEXIST") as NodeJS.ErrnoException + error.code = "EEXIST" + vi.mocked(fs.mkdir).mockRejectedValue(error) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + // Should not throw + await expect(saveHistory(["command"])).resolves.not.toThrow() + }) + + it("should log warning on write error but not throw", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockRejectedValue(new Error("Permission denied")) + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(saveHistory(["command"])).resolves.not.toThrow() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not save CLI history"), + expect.any(String), + ) + + consoleSpy.mockRestore() + }) + }) + + describe("addToHistory", () => { + it("should add new entry to history", async () => { + const mockData = { + version: 1, + entries: ["existing command"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + const result = await addToHistory("new command") + + expect(result).toEqual(["existing command", "new command"]) + }) + + it("should not add empty strings", async () => { + const mockData = { + version: 1, + entries: ["existing command"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await addToHistory("") + + expect(result).toEqual(["existing command"]) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should not add whitespace-only strings", async () => { + const mockData = { + version: 1, + entries: ["existing command"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await addToHistory(" ") + + expect(result).toEqual(["existing command"]) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should not add consecutive duplicates", async () => { + const mockData = { + version: 1, + entries: ["first", "second"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await addToHistory("second") + + expect(result).toEqual(["first", "second"]) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should add non-consecutive duplicates", async () => { + const mockData = { + version: 1, + entries: ["first", "second"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + const result = await addToHistory("first") + + expect(result).toEqual(["first", "second", "first"]) + }) + + it("should trim whitespace from entry before adding", async () => { + const mockData = { + version: 1, + entries: ["existing"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + const result = await addToHistory(" new command ") + + expect(result).toEqual(["existing", "new command"]) + }) + }) + + describe("MAX_HISTORY_ENTRIES", () => { + it("should be 500", () => { + expect(MAX_HISTORY_ENTRIES).toBe(500) + }) + }) +}) diff --git a/apps/cli/src/lib/storage/__tests__/settings.test.ts b/apps/cli/src/lib/storage/__tests__/settings.test.ts new file mode 100644 index 00000000000..3427a8a89db --- /dev/null +++ b/apps/cli/src/lib/storage/__tests__/settings.test.ts @@ -0,0 +1,256 @@ +import fs from "fs/promises" +import path from "path" + +// Use vi.hoisted to make the test directory available to the mock +// This must return the path synchronously since settings path is computed at import time +const { getTestConfigDir } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const os = require("os") + // eslint-disable-next-line @typescript-eslint/no-require-imports + const path = require("path") + const testRunId = Date.now().toString() + const testConfigDir = path.join(os.tmpdir(), `roo-cli-settings-test-${testRunId}`) + return { getTestConfigDir: () => testConfigDir } +}) + +vi.mock("../config-dir.js", () => ({ + getConfigDir: getTestConfigDir, +})) + +// Import after mocking +import { loadSettings, saveSettings, resetOnboarding, getSettingsPath } from "../settings.js" +import { OnboardingProviderChoice } from "@/types/index.js" + +// Re-derive the test config dir for use in tests (must match the hoisted one) +const actualTestConfigDir = getTestConfigDir() + +describe("Settings Storage", () => { + const expectedSettingsFile = path.join(actualTestConfigDir, "cli-settings.json") + + beforeEach(async () => { + // Clear test directory before each test + await fs.rm(actualTestConfigDir, { recursive: true, force: true }) + }) + + afterAll(async () => { + // Clean up test directory + await fs.rm(actualTestConfigDir, { recursive: true, force: true }) + }) + + describe("getSettingsPath", () => { + it("should return the correct settings file path", () => { + expect(getSettingsPath()).toBe(expectedSettingsFile) + }) + }) + + describe("loadSettings", () => { + it("should return empty object if no settings file exists", async () => { + const settings = await loadSettings() + expect(settings).toEqual({}) + }) + + it("should load saved settings", async () => { + const settingsData = { + onboardingProviderChoice: OnboardingProviderChoice.Byok, + mode: "architect", + provider: "anthropic" as const, + model: "claude-sonnet-4-20250514", + reasoningEffort: "high" as const, + } + + await fs.mkdir(actualTestConfigDir, { recursive: true }) + await fs.writeFile(expectedSettingsFile, JSON.stringify(settingsData), "utf-8") + + const loaded = await loadSettings() + expect(loaded).toEqual(settingsData) + }) + + it("should load settings with only some fields set", async () => { + const settingsData = { + mode: "code", + } + + await fs.mkdir(actualTestConfigDir, { recursive: true }) + await fs.writeFile(expectedSettingsFile, JSON.stringify(settingsData), "utf-8") + + const loaded = await loadSettings() + expect(loaded).toEqual(settingsData) + }) + }) + + describe("saveSettings", () => { + it("should save settings to disk", async () => { + await saveSettings({ mode: "debug" }) + + const savedData = await fs.readFile(expectedSettingsFile, "utf-8") + const settings = JSON.parse(savedData) + + expect(settings.mode).toBe("debug") + }) + + it("should merge settings with existing ones", async () => { + await saveSettings({ mode: "code" }) + await saveSettings({ provider: "openrouter" as const }) + + const savedData = await fs.readFile(expectedSettingsFile, "utf-8") + const settings = JSON.parse(savedData) + + expect(settings.mode).toBe("code") + expect(settings.provider).toBe("openrouter") + }) + + it("should save all default settings fields", async () => { + await saveSettings({ + mode: "architect", + provider: "anthropic" as const, + model: "claude-opus-4.6", + reasoningEffort: "medium" as const, + consecutiveMistakeLimit: 5, + }) + + const savedData = await fs.readFile(expectedSettingsFile, "utf-8") + const settings = JSON.parse(savedData) + + expect(settings.mode).toBe("architect") + expect(settings.provider).toBe("anthropic") + expect(settings.model).toBe("claude-opus-4.6") + expect(settings.reasoningEffort).toBe("medium") + expect(settings.consecutiveMistakeLimit).toBe(5) + }) + + it("should create config directory if it doesn't exist", async () => { + await saveSettings({ mode: "ask" }) + + const dirStats = await fs.stat(actualTestConfigDir) + expect(dirStats.isDirectory()).toBe(true) + }) + + // Unix file permissions don't apply on Windows - skip this test + it.skipIf(process.platform === "win32")("should set restrictive file permissions", async () => { + await saveSettings({ mode: "code" }) + + const stats = await fs.stat(expectedSettingsFile) + // Check that only owner has read/write (mode 0o600) + const mode = stats.mode & 0o777 + expect(mode).toBe(0o600) + }) + }) + + describe("resetOnboarding", () => { + it("should reset onboarding provider choice", async () => { + await saveSettings({ onboardingProviderChoice: OnboardingProviderChoice.Byok }) + + await resetOnboarding() + + const settings = await loadSettings() + expect(settings.onboardingProviderChoice).toBeUndefined() + }) + + it("should preserve other settings when resetting onboarding", async () => { + await saveSettings({ + onboardingProviderChoice: OnboardingProviderChoice.Byok, + mode: "architect", + provider: "gemini" as const, + }) + + await resetOnboarding() + + const settings = await loadSettings() + expect(settings.onboardingProviderChoice).toBeUndefined() + expect(settings.mode).toBe("architect") + expect(settings.provider).toBe("gemini") + }) + }) + + describe("default settings priority", () => { + it("should support all configurable default settings", async () => { + // Test that all the settings that can be used as defaults are properly saved and loaded + const defaultSettings = { + mode: "debug", + provider: "openai-native" as const, + model: "gpt-4o", + reasoningEffort: "low" as const, + consecutiveMistakeLimit: 7, + } + + await saveSettings(defaultSettings) + const loaded = await loadSettings() + + expect(loaded.mode).toBe("debug") + expect(loaded.provider).toBe("openai-native") + expect(loaded.model).toBe("gpt-4o") + expect(loaded.reasoningEffort).toBe("low") + expect(loaded.consecutiveMistakeLimit).toBe(7) + }) + + it("should support consecutiveMistakeLimit setting", async () => { + await saveSettings({ consecutiveMistakeLimit: 0 }) + const loaded = await loadSettings() + + expect(loaded.consecutiveMistakeLimit).toBe(0) + }) + + it("should support requireApproval setting", async () => { + await saveSettings({ requireApproval: true }) + const loaded = await loadSettings() + + expect(loaded.requireApproval).toBe(true) + }) + + it("should support all settings together including requireApproval", async () => { + const allSettings = { + mode: "architect", + provider: "anthropic" as const, + model: "claude-sonnet-4-20250514", + reasoningEffort: "high" as const, + requireApproval: true, + } + + await saveSettings(allSettings) + const loaded = await loadSettings() + + expect(loaded.mode).toBe("architect") + expect(loaded.provider).toBe("anthropic") + expect(loaded.model).toBe("claude-sonnet-4-20250514") + expect(loaded.reasoningEffort).toBe("high") + expect(loaded.requireApproval).toBe(true) + }) + + it("should support oneshot setting", async () => { + await saveSettings({ oneshot: true }) + const loaded = await loadSettings() + + expect(loaded.oneshot).toBe(true) + }) + + it("should support all settings together including oneshot", async () => { + const allSettings = { + mode: "architect", + provider: "anthropic" as const, + model: "claude-sonnet-4-20250514", + reasoningEffort: "high" as const, + consecutiveMistakeLimit: 9, + requireApproval: true, + oneshot: true, + } + + await saveSettings(allSettings) + const loaded = await loadSettings() + + expect(loaded.mode).toBe("architect") + expect(loaded.provider).toBe("anthropic") + expect(loaded.model).toBe("claude-sonnet-4-20250514") + expect(loaded.reasoningEffort).toBe("high") + expect(loaded.consecutiveMistakeLimit).toBe(9) + expect(loaded.requireApproval).toBe(true) + expect(loaded.oneshot).toBe(true) + }) + + it("should still load legacy dangerouslySkipPermissions setting", async () => { + await saveSettings({ dangerouslySkipPermissions: true }) + const loaded = await loadSettings() + + expect(loaded.dangerouslySkipPermissions).toBe(true) + }) + }) +}) diff --git a/apps/cli/src/lib/storage/config-dir.ts b/apps/cli/src/lib/storage/config-dir.ts new file mode 100644 index 00000000000..6d6542ef88f --- /dev/null +++ b/apps/cli/src/lib/storage/config-dir.ts @@ -0,0 +1,22 @@ +import fs from "fs/promises" +import os from "os" +import path from "path" + +const CONFIG_DIR = path.join(os.homedir(), ".roo") + +export function getConfigDir(): string { + return CONFIG_DIR +} + +export async function ensureConfigDir(): Promise { + try { + await fs.mkdir(CONFIG_DIR, { recursive: true }) + } catch (err) { + // Directory may already exist, that's fine. + const error = err as NodeJS.ErrnoException + + if (error.code !== "EEXIST") { + throw err + } + } +} diff --git a/apps/cli/src/lib/storage/ephemeral.ts b/apps/cli/src/lib/storage/ephemeral.ts new file mode 100644 index 00000000000..28984cfe587 --- /dev/null +++ b/apps/cli/src/lib/storage/ephemeral.ts @@ -0,0 +1,10 @@ +import path from "path" +import os from "os" +import fs from "fs" + +export async function createEphemeralStorageDir(): Promise { + const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}` + const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`) + await fs.promises.mkdir(tmpDir, { recursive: true }) + return tmpDir +} diff --git a/apps/cli/src/lib/storage/history.ts b/apps/cli/src/lib/storage/history.ts new file mode 100644 index 00000000000..f00a976b106 --- /dev/null +++ b/apps/cli/src/lib/storage/history.ts @@ -0,0 +1,109 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import { ensureConfigDir, getConfigDir } from "./config-dir.js" + +/** Maximum number of history entries to keep */ +export const MAX_HISTORY_ENTRIES = 500 + +/** History file format version for future migrations */ +const HISTORY_VERSION = 1 + +interface HistoryData { + version: number + entries: string[] +} + +/** + * Get the path to the history file + */ +export function getHistoryFilePath(): string { + return path.join(getConfigDir(), "cli-history.json") +} + +/** + * Load history entries from file + * Returns empty array if file doesn't exist or is invalid + */ +export async function loadHistory(): Promise { + const filePath = getHistoryFilePath() + + try { + const content = await fs.readFile(filePath, "utf-8") + const data: HistoryData = JSON.parse(content) + + // Validate structure + if (!data || typeof data !== "object") { + return [] + } + + if (!Array.isArray(data.entries)) { + return [] + } + + // Filter to only valid strings + return data.entries.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + } catch (err) { + const error = err as NodeJS.ErrnoException + // File doesn't exist - that's expected on first run + if (error.code === "ENOENT") { + return [] + } + + // JSON parse error or other issue - log and return empty + console.error("Warning: Could not load CLI history:", error.message) + return [] + } +} + +/** + * Save history entries to file + * Creates the .roo directory if needed + * Trims to MAX_HISTORY_ENTRIES + */ +export async function saveHistory(entries: string[]): Promise { + const filePath = getHistoryFilePath() + + // Trim to max entries (keep most recent) + const trimmedEntries = entries.slice(-MAX_HISTORY_ENTRIES) + + const data: HistoryData = { + version: HISTORY_VERSION, + entries: trimmedEntries, + } + + try { + await ensureConfigDir() + await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf-8") + } catch (err) { + const error = err as NodeJS.ErrnoException + // Log but don't throw - history persistence is not critical + console.error("Warning: Could not save CLI history:", error.message) + } +} + +/** + * Add a new entry to history and save + * Avoids adding consecutive duplicates or empty entries + * Returns the updated history array + */ +export async function addToHistory(entry: string): Promise { + const trimmed = entry.trim() + + // Don't add empty entries + if (!trimmed) { + return await loadHistory() + } + + const history = await loadHistory() + + // Don't add consecutive duplicates + if (history.length > 0 && history[history.length - 1] === trimmed) { + return history + } + + const updated = [...history, trimmed] + await saveHistory(updated) + + return updated.slice(-MAX_HISTORY_ENTRIES) +} diff --git a/apps/cli/src/lib/storage/index.ts b/apps/cli/src/lib/storage/index.ts new file mode 100644 index 00000000000..89246376432 --- /dev/null +++ b/apps/cli/src/lib/storage/index.ts @@ -0,0 +1,3 @@ +export * from "./config-dir.js" +export * from "./settings.js" +export * from "./ephemeral.js" diff --git a/apps/cli/src/lib/storage/settings.ts b/apps/cli/src/lib/storage/settings.ts new file mode 100644 index 00000000000..86a2d9243e5 --- /dev/null +++ b/apps/cli/src/lib/storage/settings.ts @@ -0,0 +1,40 @@ +import fs from "fs/promises" +import path from "path" + +import type { CliSettings } from "@/types/index.js" + +import { getConfigDir } from "./index.js" + +export function getSettingsPath(): string { + return path.join(getConfigDir(), "cli-settings.json") +} + +export async function loadSettings(): Promise { + try { + const settingsPath = getSettingsPath() + const data = await fs.readFile(settingsPath, "utf-8") + return JSON.parse(data) as CliSettings + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return {} + } + + throw error + } +} + +export async function saveSettings(settings: Partial): Promise { + const configDir = getConfigDir() + await fs.mkdir(configDir, { recursive: true }) + + const existing = await loadSettings() + const merged = { ...existing, ...settings } + + await fs.writeFile(getSettingsPath(), JSON.stringify(merged, null, 2), { + mode: 0o600, + }) +} + +export async function resetOnboarding(): Promise { + await saveSettings({ onboardingProviderChoice: undefined }) +} diff --git a/apps/cli/src/lib/task-history/__tests__/index.test.ts b/apps/cli/src/lib/task-history/__tests__/index.test.ts new file mode 100644 index 00000000000..58b0692b2b3 --- /dev/null +++ b/apps/cli/src/lib/task-history/__tests__/index.test.ts @@ -0,0 +1,75 @@ +import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli" + +import { + filterSessionsForWorkspace, + getDefaultCliTaskStoragePath, + readWorkspaceTaskSessions, + resolveWorkspaceResumeSessionId, +} from "../index.js" + +vi.mock("@roo-code/core/cli", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readTaskSessionsFromStoragePath: vi.fn(), + } +}) + +describe("task history workspace helpers", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("filters sessions to the current workspace and sorts newest first", () => { + const result = filterSessionsForWorkspace( + [ + { id: "a", task: "A", ts: 10, workspace: "/workspace/project" }, + { id: "b", task: "B", ts: 30, workspace: "/workspace/project/" }, + { id: "c", task: "C", ts: 20, workspace: "/workspace/other" }, + { id: "d", task: "D", ts: 40 }, + ], + "/workspace/project", + ) + + expect(result.map((session) => session.id)).toEqual(["b", "a"]) + }) + + it("reads from storage path and applies workspace filtering", async () => { + vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([ + { id: "a", task: "A", ts: 10, workspace: "/workspace/project" }, + { id: "b", task: "B", ts: 30, workspace: "/workspace/other" }, + ]) + + const result = await readWorkspaceTaskSessions("/workspace/project", "/custom/storage") + + expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith("/custom/storage") + expect(result).toEqual([{ id: "a", task: "A", ts: 10, workspace: "/workspace/project" }]) + }) + + it("returns the expected default CLI storage path", () => { + expect(getDefaultCliTaskStoragePath()).toContain(".vscode-mock") + expect(getDefaultCliTaskStoragePath()).toContain("global-storage") + }) + + it("resolves explicit session id only when it exists in current workspace sessions", () => { + const sessions = [ + { id: "a", task: "A", ts: 10, workspace: "/workspace/project" }, + { id: "b", task: "B", ts: 20, workspace: "/workspace/project" }, + ] + + expect(resolveWorkspaceResumeSessionId(sessions, "a")).toBe("a") + expect(() => resolveWorkspaceResumeSessionId(sessions, "missing")).toThrow( + "Session not found in current workspace", + ) + }) + + it("resolves continue to most recent session and errors when no sessions exist", () => { + const sessions = [ + { id: "newer", task: "Newer", ts: 30, workspace: "/workspace/project" }, + { id: "older", task: "Older", ts: 10, workspace: "/workspace/project" }, + ] + + expect(resolveWorkspaceResumeSessionId(sessions)).toBe("newer") + expect(() => resolveWorkspaceResumeSessionId([])).toThrow("No previous tasks found to continue") + }) +}) diff --git a/apps/cli/src/lib/task-history/index.ts b/apps/cli/src/lib/task-history/index.ts new file mode 100644 index 00000000000..3be2d45d4c0 --- /dev/null +++ b/apps/cli/src/lib/task-history/index.ts @@ -0,0 +1,44 @@ +import os from "os" +import path from "path" + +import { readTaskSessionsFromStoragePath, type TaskSessionEntry } from "@roo-code/core/cli" + +import { arePathsEqual } from "@/lib/utils/path.js" + +const DEFAULT_CLI_TASK_STORAGE_PATH = path.join(os.homedir(), ".vscode-mock", "global-storage") + +export function getDefaultCliTaskStoragePath(): string { + return DEFAULT_CLI_TASK_STORAGE_PATH +} + +export function filterSessionsForWorkspace(sessions: TaskSessionEntry[], workspacePath: string): TaskSessionEntry[] { + return sessions + .filter((session) => typeof session.workspace === "string" && arePathsEqual(session.workspace, workspacePath)) + .sort((a, b) => b.ts - a.ts) +} + +export async function readWorkspaceTaskSessions( + workspacePath: string, + storagePath = DEFAULT_CLI_TASK_STORAGE_PATH, +): Promise { + const sessions = await readTaskSessionsFromStoragePath(storagePath) + return filterSessionsForWorkspace(sessions, workspacePath) +} + +export function resolveWorkspaceResumeSessionId(sessions: TaskSessionEntry[], requestedSessionId?: string): string { + if (requestedSessionId) { + const hasRequestedSession = sessions.some((session) => session.id === requestedSessionId) + if (!hasRequestedSession) { + throw new Error(`Session not found in current workspace: ${requestedSessionId}`) + } + + return requestedSessionId + } + + const mostRecentSessionId = sessions[0]?.id + if (!mostRecentSessionId) { + throw new Error("No previous tasks found to continue in this workspace.") + } + + return mostRecentSessionId +} diff --git a/apps/cli/src/lib/utils/__tests__/commands.test.ts b/apps/cli/src/lib/utils/__tests__/commands.test.ts new file mode 100644 index 00000000000..ccae8401bda --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/commands.test.ts @@ -0,0 +1,102 @@ +import { + type GlobalCommand, + type GlobalCommandAction, + GLOBAL_COMMANDS, + getGlobalCommand, + getGlobalCommandsForAutocomplete, +} from "../commands.js" + +describe("globalCommands", () => { + describe("GLOBAL_COMMANDS", () => { + it("should contain the /new command", () => { + const newCommand = GLOBAL_COMMANDS.find((cmd) => cmd.name === "new") + expect(newCommand).toBeDefined() + expect(newCommand?.action).toBe("clearTask") + expect(newCommand?.description).toBe("Start a new task") + }) + + it("should have valid structure for all commands", () => { + for (const cmd of GLOBAL_COMMANDS) { + expect(cmd.name).toBeTruthy() + expect(typeof cmd.name).toBe("string") + expect(cmd.description).toBeTruthy() + expect(typeof cmd.description).toBe("string") + expect(cmd.action).toBeTruthy() + expect(typeof cmd.action).toBe("string") + } + }) + }) + + describe("getGlobalCommand", () => { + it("should return the command when found", () => { + const cmd = getGlobalCommand("new") + expect(cmd).toBeDefined() + expect(cmd?.name).toBe("new") + expect(cmd?.action).toBe("clearTask") + }) + + it("should return undefined for unknown commands", () => { + const cmd = getGlobalCommand("unknown-command") + expect(cmd).toBeUndefined() + }) + + it("should be case-sensitive", () => { + const cmd = getGlobalCommand("NEW") + expect(cmd).toBeUndefined() + }) + }) + + describe("getGlobalCommandsForAutocomplete", () => { + it("should return commands in autocomplete format", () => { + const commands = getGlobalCommandsForAutocomplete() + expect(commands.length).toBe(GLOBAL_COMMANDS.length) + + for (const cmd of commands) { + expect(cmd.name).toBeTruthy() + expect(cmd.source).toBe("global") + expect(cmd.action).toBeTruthy() + } + }) + + it("should include the /new command with correct format", () => { + const commands = getGlobalCommandsForAutocomplete() + const newCommand = commands.find((cmd) => cmd.name === "new") + + expect(newCommand).toBeDefined() + expect(newCommand?.description).toBe("Start a new task") + expect(newCommand?.source).toBe("global") + expect(newCommand?.action).toBe("clearTask") + }) + + it("should not include argumentHint for action commands", () => { + const commands = getGlobalCommandsForAutocomplete() + // Action commands don't have argument hints + for (const cmd of commands) { + expect(cmd).not.toHaveProperty("argumentHint") + } + }) + }) + + describe("type safety", () => { + it("should have valid GlobalCommandAction types", () => { + // This test ensures the type is properly constrained + const validActions: GlobalCommandAction[] = ["clearTask"] + + for (const cmd of GLOBAL_COMMANDS) { + expect(validActions).toContain(cmd.action) + } + }) + + it("should match GlobalCommand interface", () => { + const testCommand: GlobalCommand = { + name: "test", + description: "Test command", + action: "clearTask", + } + + expect(testCommand.name).toBe("test") + expect(testCommand.description).toBe("Test command") + expect(testCommand.action).toBe("clearTask") + }) + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/extension.test.ts b/apps/cli/src/lib/utils/__tests__/extension.test.ts new file mode 100644 index 00000000000..4b4a2db5850 --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/extension.test.ts @@ -0,0 +1,113 @@ +import fs from "fs" +import path from "path" + +import { getDefaultExtensionPath } from "../extension.js" + +vi.mock("fs") + +describe("getDefaultExtensionPath", () => { + const originalEnv = process.env + + beforeEach(() => { + vi.resetAllMocks() + // Reset process.env to avoid ROO_EXTENSION_PATH from installed CLI affecting tests. + process.env = { ...originalEnv } + delete process.env.ROO_EXTENSION_PATH + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("should return monorepo path when extension.js exists there", () => { + const mockDirname = "/test/apps/cli/dist" + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") + + // Walk-up: dist/ has no package.json, apps/cli/ does + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join(mockDirname, "package.json")) { + return false + } + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + if (s === path.join(expectedMonorepoPath, "extension.js")) { + return true + } + + return false + }) + + 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("/test/apps/cli", "extension") + + // Walk-up finds package.json at apps/cli/, but no extension.js in monorepo path + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + return false + }) + + const result = getDefaultExtensionPath(mockDirname) + + expect(result).toBe(expectedPackagePath) + }) + + it("should check monorepo path first", () => { + const mockDirname = "/test/apps/cli/dist" + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + return false + }) + + getDefaultExtensionPath(mockDirname) + + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") + expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js")) + }) + + it("should work when called from source directory (tsx dev)", () => { + const mockDirname = "/test/apps/cli/src/commands/cli" + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") + + // Walk-up: no package.json in src subdirs, found at apps/cli/ + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + if (s === path.join(expectedMonorepoPath, "extension.js")) { + return true + } + + return false + }) + + const result = getDefaultExtensionPath(mockDirname) + + expect(result).toBe(expectedMonorepoPath) + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/guards.test.ts b/apps/cli/src/lib/utils/__tests__/guards.test.ts new file mode 100644 index 00000000000..f59eeb506d4 --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/guards.test.ts @@ -0,0 +1,27 @@ +import { isRecord } from "../guards.js" + +describe("isRecord", () => { + it("returns true for plain objects", () => { + expect(isRecord({})).toBe(true) + expect(isRecord({ a: 1 })).toBe(true) + }) + + it("returns true for arrays (arrays are objects)", () => { + expect(isRecord([])).toBe(true) + }) + + it("returns false for null", () => { + expect(isRecord(null)).toBe(false) + }) + + it("returns false for undefined", () => { + expect(isRecord(undefined)).toBe(false) + }) + + it("returns false for primitives", () => { + expect(isRecord("string")).toBe(false) + expect(isRecord(42)).toBe(false) + expect(isRecord(true)).toBe(false) + expect(isRecord(Symbol("s"))).toBe(false) + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/input.test.ts b/apps/cli/src/lib/utils/__tests__/input.test.ts new file mode 100644 index 00000000000..c346e60d6d0 --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/input.test.ts @@ -0,0 +1,128 @@ +import type { Key } from "ink" + +import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../input.js" + +function createKey(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + ...overrides, + } +} + +describe("globalInputSequences", () => { + describe("GLOBAL_INPUT_SEQUENCES registry", () => { + it("should have ctrl-c registered", () => { + const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-c") + expect(seq).toBeDefined() + expect(seq?.description).toContain("Exit") + }) + + it("should have ctrl-m registered", () => { + const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-m") + expect(seq).toBeDefined() + expect(seq?.description).toContain("mode") + }) + }) + + describe("isGlobalInputSequence", () => { + describe("Ctrl+C detection", () => { + it("should match standard Ctrl+C", () => { + const result = isGlobalInputSequence("c", createKey({ ctrl: true })) + expect(result).toBeDefined() + expect(result?.id).toBe("ctrl-c") + }) + + it("should not match plain 'c' key", () => { + const result = isGlobalInputSequence("c", createKey()) + expect(result).toBeUndefined() + }) + }) + + describe("Ctrl+M detection", () => { + it("should match standard Ctrl+M", () => { + const result = isGlobalInputSequence("m", createKey({ ctrl: true })) + expect(result).toBeDefined() + expect(result?.id).toBe("ctrl-m") + }) + + it("should match CSI u encoding for Ctrl+M", () => { + const result = isGlobalInputSequence("\x1b[109;5u", createKey()) + expect(result).toBeDefined() + expect(result?.id).toBe("ctrl-m") + }) + + it("should match input ending with CSI u sequence", () => { + const result = isGlobalInputSequence("[109;5u", createKey()) + expect(result).toBeDefined() + expect(result?.id).toBe("ctrl-m") + }) + + it("should not match plain 'm' key", () => { + const result = isGlobalInputSequence("m", createKey()) + expect(result).toBeUndefined() + }) + }) + + it("should return undefined for non-global sequences", () => { + const result = isGlobalInputSequence("a", createKey()) + expect(result).toBeUndefined() + }) + + it("should return undefined for regular text input", () => { + const result = isGlobalInputSequence("hello", createKey()) + expect(result).toBeUndefined() + }) + }) + + describe("matchesGlobalSequence", () => { + it("should return true for matching sequence ID", () => { + const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-c") + expect(result).toBe(true) + }) + + it("should return false for non-matching sequence ID", () => { + const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-m") + expect(result).toBe(false) + }) + + it("should return false for non-existent sequence ID", () => { + const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "non-existent") + expect(result).toBe(false) + }) + + it("should match ctrl-m with CSI u encoding", () => { + const result = matchesGlobalSequence("\x1b[109;5u", createKey(), "ctrl-m") + expect(result).toBe(true) + }) + }) + + describe("extensibility", () => { + it("should have unique IDs for all sequences", () => { + const ids = GLOBAL_INPUT_SEQUENCES.map((s) => s.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(ids.length) + }) + + it("should have descriptions for all sequences", () => { + for (const seq of GLOBAL_INPUT_SEQUENCES) { + expect(seq.description).toBeTruthy() + expect(seq.description.length).toBeGreaterThan(0) + } + }) + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/path.test.ts b/apps/cli/src/lib/utils/__tests__/path.test.ts new file mode 100644 index 00000000000..69c79ca196f --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/path.test.ts @@ -0,0 +1,68 @@ +import { normalizePath, arePathsEqual } from "../path.js" + +// Helper to create platform-specific expected paths +const expectedPath = (...segments: string[]) => { + // On Windows, path.normalize converts forward slashes to backslashes + // and paths like /Users become \Users (without a drive letter) + if (process.platform === "win32") { + return "\\" + segments.join("\\") + } + + return "/" + segments.join("/") +} + +describe("normalizePath", () => { + it("should remove trailing slashes", () => { + expect(normalizePath("/Users/test/project/")).toBe(expectedPath("Users", "test", "project")) + expect(normalizePath("/Users/test/project//")).toBe(expectedPath("Users", "test", "project")) + }) + + it("should handle paths without trailing slashes", () => { + expect(normalizePath("/Users/test/project")).toBe(expectedPath("Users", "test", "project")) + }) + + it("should normalize path separators", () => { + // path.normalize handles this + expect(normalizePath("/Users//test/project")).toBe(expectedPath("Users", "test", "project")) + }) +}) + +describe("arePathsEqual", () => { + it("should return true for identical paths", () => { + expect(arePathsEqual("/Users/test/project", "/Users/test/project")).toBe(true) + }) + + it("should return true for paths differing only by trailing slash", () => { + expect(arePathsEqual("/Users/test/project", "/Users/test/project/")).toBe(true) + expect(arePathsEqual("/Users/test/project/", "/Users/test/project")).toBe(true) + }) + + it("should return false for undefined or empty paths", () => { + expect(arePathsEqual(undefined, "/Users/test/project")).toBe(false) + expect(arePathsEqual("/Users/test/project", undefined)).toBe(false) + expect(arePathsEqual(undefined, undefined)).toBe(false) + expect(arePathsEqual("", "/Users/test/project")).toBe(false) + expect(arePathsEqual("/Users/test/project", "")).toBe(false) + }) + + it("should return false for different paths", () => { + expect(arePathsEqual("/Users/test/project1", "/Users/test/project2")).toBe(false) + expect(arePathsEqual("/Users/test/project", "/Users/other/project")).toBe(false) + }) + + // Case sensitivity behavior depends on platform + if (process.platform === "darwin" || process.platform === "win32") { + it("should be case-insensitive on macOS/Windows", () => { + expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(true) + expect(arePathsEqual("/USERS/TEST/PROJECT", "/Users/test/project")).toBe(true) + }) + } else { + it("should be case-sensitive on Linux", () => { + expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(false) + }) + } + + it("should handle paths with multiple trailing slashes", () => { + expect(arePathsEqual("/Users/test/project///", "/Users/test/project")).toBe(true) + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/provider.test.ts b/apps/cli/src/lib/utils/__tests__/provider.test.ts new file mode 100644 index 00000000000..70d8a2a5557 --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/provider.test.ts @@ -0,0 +1,34 @@ +import { getApiKeyFromEnv } from "../provider.js" + +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-native")).toBe("test-openai-key") + }) + + it("should return undefined when API key is not set", () => { + delete process.env.ANTHROPIC_API_KEY + expect(getApiKeyFromEnv("anthropic")).toBeUndefined() + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/shell.test.ts b/apps/cli/src/lib/utils/__tests__/shell.test.ts new file mode 100644 index 00000000000..7e94131c3b5 --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/shell.test.ts @@ -0,0 +1,54 @@ +import fs from "fs/promises" + +import { validateTerminalShellPath } from "../shell.js" + +vi.mock("fs/promises", () => ({ + default: { + access: vi.fn(), + stat: vi.fn(), + }, +})) + +describe("validateTerminalShellPath", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.stat).mockResolvedValue({ + isFile: () => true, + } as unknown as Awaited>) + }) + + it("returns invalid for an empty path", async () => { + const result = await validateTerminalShellPath(" ") + expect(result).toEqual({ valid: false, reason: "shell path cannot be empty" }) + }) + + it("returns invalid for a relative path", async () => { + const result = await validateTerminalShellPath("bin/bash") + expect(result).toEqual({ valid: false, reason: "shell path must be absolute" }) + }) + + it("returns valid for an absolute executable path", async () => { + const result = await validateTerminalShellPath("/bin/bash") + expect(result).toEqual({ valid: true, shellPath: "/bin/bash" }) + }) + + it("returns invalid when the shell path cannot be accessed", async () => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error("ENOENT")) + const result = await validateTerminalShellPath("/missing/shell") + + expect(result.valid).toBe(false) + if (!result.valid) { + expect(result.reason).toContain("shell path") + } + }) + + it("returns invalid when the shell path points to a directory", async () => { + vi.mocked(fs.stat).mockResolvedValueOnce({ + isFile: () => false, + } as unknown as Awaited>) + const result = await validateTerminalShellPath("/bin") + + expect(result).toEqual({ valid: false, reason: "shell path must point to a file" }) + }) +}) diff --git a/apps/cli/src/lib/utils/commands.ts b/apps/cli/src/lib/utils/commands.ts new file mode 100644 index 00000000000..32459e0a2a4 --- /dev/null +++ b/apps/cli/src/lib/utils/commands.ts @@ -0,0 +1,62 @@ +/** + * CLI-specific global slash commands + * + * These commands are handled entirely within the CLI and trigger actions + * by sending messages to the extension host. They are separate from the + * extension's built-in commands which expand into prompt content. + */ + +/** + * Action types that can be triggered by global commands. + * Each action corresponds to a message type sent to the extension host. + */ +export type GlobalCommandAction = "clearTask" + +/** + * Definition of a CLI global command + */ +export interface GlobalCommand { + /** Command name (without the leading /) */ + name: string + /** Description shown in the autocomplete picker */ + description: string + /** Action to trigger when the command is executed */ + action: GlobalCommandAction +} + +/** + * CLI-specific global slash commands + * These commands trigger actions rather than expanding into prompt content. + */ +export const GLOBAL_COMMANDS: GlobalCommand[] = [ + { + name: "new", + description: "Start a new task", + action: "clearTask", + }, +] + +/** + * Get a global command by name + */ +export function getGlobalCommand(name: string): GlobalCommand | undefined { + return GLOBAL_COMMANDS.find((cmd) => cmd.name === name) +} + +/** + * Get global commands formatted for autocomplete + * Returns commands in the SlashCommandResult format expected by the autocomplete trigger + */ +export function getGlobalCommandsForAutocomplete(): Array<{ + name: string + description?: string + source: "global" | "project" | "built-in" + action?: string +}> { + return GLOBAL_COMMANDS.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + source: "global" as const, + action: cmd.action, + })) +} diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts new file mode 100644 index 00000000000..5cd58b55a8f --- /dev/null +++ b/apps/cli/src/lib/utils/context-window.ts @@ -0,0 +1,61 @@ +import type { ProviderSettings } from "@roo-code/types" + +import type { RouterModels } from "@/ui/store.js" + +const DEFAULT_CONTEXT_WINDOW = 200_000 + +/** + * Looks up the context window size for the current model from routerModels. + * + * @param routerModels - The router models data containing model info per provider + * @param apiConfiguration - The current API configuration with provider and model ID + * @returns The context window size, or DEFAULT_CONTEXT_WINDOW (200K) if not found + */ +export function getContextWindow(routerModels: RouterModels | null, apiConfiguration: ProviderSettings | null): number { + if (!routerModels || !apiConfiguration) { + return DEFAULT_CONTEXT_WINDOW + } + + const provider = apiConfiguration.apiProvider + const modelId = getModelIdForProvider(apiConfiguration) + + if (!provider || !modelId) { + return DEFAULT_CONTEXT_WINDOW + } + + const providerModels = routerModels[provider] + const modelInfo = providerModels?.[modelId] + + return modelInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW +} + +/** + * Gets the model ID from the API configuration based on the provider type. + * + * Different providers store their model ID in different fields of ProviderSettings. + */ +function getModelIdForProvider(config: ProviderSettings): string | undefined { + switch (config.apiProvider) { + case "openrouter": + return config.openRouterModelId + case "ollama": + return config.ollamaModelId + case "lmstudio": + return config.lmStudioModelId + case "openai": + return config.openAiModelId + case "requesty": + return config.requestyModelId + case "unbound": + return config.unboundModelId + case "litellm": + return config.litellmModelId + case "vercel-ai-gateway": + return config.vercelAiGatewayModelId + default: + // For anthropic, bedrock, vertex, gemini, xai, etc. + return config.apiModelId + } +} + +export { DEFAULT_CONTEXT_WINDOW } diff --git a/apps/cli/src/lib/utils/extension.ts b/apps/cli/src/lib/utils/extension.ts new file mode 100644 index 00000000000..f49b2df8651 --- /dev/null +++ b/apps/cli/src/lib/utils/extension.ts @@ -0,0 +1,42 @@ +import path from "path" +import fs from "fs" + +/** + * 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 + } + } + + // Find the CLI package root (apps/cli) by walking up to the nearest package.json. + // This works whether called from dist/ (bundled) or src/commands/cli/ (tsx dev). + let packageRoot = dirname + + while (packageRoot !== path.dirname(packageRoot)) { + if (fs.existsSync(path.join(packageRoot, "package.json"))) { + break + } + + packageRoot = path.dirname(packageRoot) + } + + // The extension is at ../../src/dist relative to apps/cli (monorepo/src/dist) + const monorepoPath = path.resolve(packageRoot, "../../src/dist") + + if (fs.existsSync(path.join(monorepoPath, "extension.js"))) { + return monorepoPath + } + + // Fallback: when installed via curl script, extension is at apps/cli/extension + const packagePath = path.resolve(packageRoot, "extension") + return packagePath +} diff --git a/apps/cli/src/lib/utils/guards.ts b/apps/cli/src/lib/utils/guards.ts new file mode 100644 index 00000000000..a901f1a6584 --- /dev/null +++ b/apps/cli/src/lib/utils/guards.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} diff --git a/apps/cli/src/lib/utils/input.ts b/apps/cli/src/lib/utils/input.ts new file mode 100644 index 00000000000..792f38ee59d --- /dev/null +++ b/apps/cli/src/lib/utils/input.ts @@ -0,0 +1,122 @@ +/** + * Global Input Sequences Registry + * + * This module centralizes the definition of input sequences that should be + * handled at the App level (or other top-level components) and ignored by + * child components like MultilineTextInput. + * + * When adding new global shortcuts: + * 1. Add the sequence definition to GLOBAL_INPUT_SEQUENCES + * 2. The App.tsx useInput handler should check for and handle the sequence + * 3. Child components automatically ignore these via isGlobalInputSequence() + */ + +import type { Key } from "ink" + +/** + * Definition of a global input sequence + */ +export interface GlobalInputSequence { + /** Unique identifier for the sequence */ + id: string + /** Human-readable description */ + description: string + /** + * Matcher function - returns true if the input matches this sequence. + * @param input - The raw input string from useInput + * @param key - The parsed key object from useInput + */ + matches: (input: string, key: Key) => boolean +} + +/** + * Registry of all global input sequences that should be handled at the App level + * and ignored by child components (like MultilineTextInput). + * + * Add new global shortcuts here to ensure they're properly handled throughout + * the application. + */ +export const GLOBAL_INPUT_SEQUENCES: GlobalInputSequence[] = [ + { + id: "ctrl-c", + description: "Exit application (with confirmation)", + matches: (input, key) => key.ctrl && input === "c", + }, + { + id: "ctrl-m", + description: "Cycle through modes", + matches: (input, key) => { + // Standard Ctrl+M detection + if (key.ctrl && input === "m") return true + // CSI u encoding: ESC [ 109 ; 5 u (kitty keyboard protocol) + // 109 = 'm' ASCII code, 5 = Ctrl modifier + if (input === "\x1b[109;5u") return true + if (input.endsWith("[109;5u")) return true + return false + }, + }, + { + id: "ctrl-t", + description: "Toggle TODO list viewer", + matches: (input, key) => { + // Standard Ctrl+T detection + if (key.ctrl && input === "t") return true + // CSI u encoding: ESC [ 116 ; 5 u (kitty keyboard protocol) + // 116 = 't' ASCII code, 5 = Ctrl modifier + if (input === "\x1b[116;5u") return true + if (input.endsWith("[116;5u")) return true + return false + }, + }, + // Add more global sequences here as needed: + // { + // id: "ctrl-n", + // description: "New task", + // matches: (input, key) => key.ctrl && input === "n", + // }, +] + +/** + * Check if an input matches any global input sequence. + * + * Use this in child components (like MultilineTextInput) to determine + * if input should be ignored because it will be handled by a parent component. + * + * @param input - The raw input string from useInput + * @param key - The parsed key object from useInput + * @returns The matching GlobalInputSequence, or undefined if no match + * + * @example + * ```tsx + * useInput((input, key) => { + * // Ignore inputs handled at App level + * if (isGlobalInputSequence(input, key)) { + * return + * } + * // Handle component-specific input... + * }) + * ``` + */ +export function isGlobalInputSequence(input: string, key: Key): GlobalInputSequence | undefined { + return GLOBAL_INPUT_SEQUENCES.find((seq) => seq.matches(input, key)) +} + +/** + * Check if an input matches a specific global input sequence by ID. + * + * @param input - The raw input string from useInput + * @param key - The parsed key object from useInput + * @param id - The sequence ID to check for + * @returns true if the input matches the specified sequence + * + * @example + * ```tsx + * if (matchesGlobalSequence(input, key, "ctrl-m")) { + * // Handle mode cycling + * } + * ``` + */ +export function matchesGlobalSequence(input: string, key: Key, id: string): boolean { + const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === id) + return seq ? seq.matches(input, key) : false +} diff --git a/apps/cli/src/lib/utils/path.ts b/apps/cli/src/lib/utils/path.ts new file mode 100644 index 00000000000..ccaecd80819 --- /dev/null +++ b/apps/cli/src/lib/utils/path.ts @@ -0,0 +1,35 @@ +import * as path from "path" + +/** + * Normalize a path by removing trailing slashes and converting separators. + * This handles cross-platform path comparison issues. + */ +export function normalizePath(p: string): string { + // Remove trailing slashes + let normalized = p.replace(/[/\\]+$/, "") + // Convert to consistent separators using path.normalize + normalized = path.normalize(normalized) + return normalized +} + +/** + * Compare two paths for equality, handling: + * - Trailing slashes + * - Path separator differences + * - Case sensitivity (case-insensitive on Windows/macOS) + */ +export function arePathsEqual(path1?: string, path2?: string): boolean { + if (!path1 || !path2) { + return false + } + + const normalizedPath1 = normalizePath(path1) + const normalizedPath2 = normalizePath(path2) + + // On Windows and macOS, file paths are case-insensitive + if (process.platform === "win32" || process.platform === "darwin") { + return normalizedPath1.toLowerCase() === normalizedPath2.toLowerCase() + } + + return normalizedPath1 === normalizedPath2 +} diff --git a/apps/cli/src/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts new file mode 100644 index 00000000000..26beaf90c45 --- /dev/null +++ b/apps/cli/src/lib/utils/provider.ts @@ -0,0 +1,56 @@ +import { RooCodeSettings } from "@roo-code/types" + +import type { SupportedProvider } from "@/types/index.js" + +const envVarMap: Record = { + anthropic: "ANTHROPIC_API_KEY", + "openai-native": "OPENAI_API_KEY", + gemini: "GOOGLE_API_KEY", + openrouter: "OPENROUTER_API_KEY", + "vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY", +} + +export function getEnvVarName(provider: SupportedProvider): string { + return envVarMap[provider] +} + +export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined { + const envVar = getEnvVarName(provider) + return process.env[envVar] +} + +export function getProviderSettings( + provider: SupportedProvider, + apiKey: string | undefined, + model: string | undefined, +): RooCodeSettings { + const config: RooCodeSettings = { apiProvider: provider } + + switch (provider) { + case "anthropic": + if (apiKey) config.apiKey = apiKey + if (model) config.apiModelId = model + break + case "openai-native": + if (apiKey) config.openAiNativeApiKey = apiKey + if (model) config.apiModelId = model + break + case "gemini": + if (apiKey) config.geminiApiKey = apiKey + if (model) config.apiModelId = model + break + case "openrouter": + if (apiKey) config.openRouterApiKey = apiKey + if (model) config.openRouterModelId = model + break + case "vercel-ai-gateway": + if (apiKey) config.vercelAiGatewayApiKey = apiKey + if (model) config.vercelAiGatewayModelId = model + break + default: + if (apiKey) config.apiKey = apiKey + if (model) config.apiModelId = model + } + + return config +} diff --git a/apps/cli/src/lib/utils/session-id.ts b/apps/cli/src/lib/utils/session-id.ts new file mode 100644 index 00000000000..6bd5b065670 --- /dev/null +++ b/apps/cli/src/lib/utils/session-id.ts @@ -0,0 +1,5 @@ +const SESSION_ID_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export function isValidSessionId(value: string): boolean { + return SESSION_ID_UUID_PATTERN.test(value) +} diff --git a/apps/cli/src/lib/utils/shell.ts b/apps/cli/src/lib/utils/shell.ts new file mode 100644 index 00000000000..548df919b2b --- /dev/null +++ b/apps/cli/src/lib/utils/shell.ts @@ -0,0 +1,47 @@ +import fs from "fs/promises" +import { constants as fsConstants } from "fs" +import path from "path" + +export type TerminalShellValidationResult = + | { + valid: true + shellPath: string + } + | { + valid: false + reason: string + } + +export async function validateTerminalShellPath(rawShellPath: string): Promise { + const shellPath = rawShellPath.trim() + + if (!shellPath) { + return { valid: false, reason: "shell path cannot be empty" } + } + + if (!path.isAbsolute(shellPath)) { + return { valid: false, reason: "shell path must be absolute" } + } + + try { + const stats = await fs.stat(shellPath) + + if (!stats.isFile()) { + return { valid: false, reason: "shell path must point to a file" } + } + + if (process.platform !== "win32") { + await fs.access(shellPath, fsConstants.X_OK) + } + } catch { + return { + valid: false, + reason: + process.platform === "win32" + ? "shell path does not exist or is not a file" + : "shell path does not exist, is not a file, or is not executable", + } + } + + return { valid: true, shellPath } +} diff --git a/apps/cli/src/lib/utils/version.ts b/apps/cli/src/lib/utils/version.ts new file mode 100644 index 00000000000..c599963bdc6 --- /dev/null +++ b/apps/cli/src/lib/utils/version.ts @@ -0,0 +1,24 @@ +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +// Walk up from the current file to find the nearest package.json. +// This works whether running from source (tsx src/lib/utils/) or bundle (dist/). +function findVersion(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)) + + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, "package.json") + + if (fs.existsSync(candidate)) { + const packageJson = JSON.parse(fs.readFileSync(candidate, "utf-8")) + return packageJson.version + } + + dir = path.dirname(dir) + } + + return "0.0.0" +} + +export const VERSION = findVersion() diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts new file mode 100644 index 00000000000..007cfa0783f --- /dev/null +++ b/apps/cli/src/types/constants.ts @@ -0,0 +1,23 @@ +import { reasoningEffortsExtended } from "@roo-code/types" + +export const DEFAULT_FLAGS = { + mode: "code", + reasoningEffort: "medium" as const, + model: "anthropic/claude-opus-4.6", + consecutiveMistakeLimit: 10, +} + +export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] + +/** + * Default timeout in seconds for auto-approving followup questions. + * Used in both the TUI (App.tsx) and the extension host (extension-host.ts). + */ +export const FOLLOWUP_TIMEOUT_SECONDS = 60 + +export const ASCII_ROO = ` _,' ___ + <__\\__/ \\ + \\_ / _\\ + \\,\\ / \\\\ + // \\\\ + ,/' \`\\_,` diff --git a/apps/cli/src/types/index.ts b/apps/cli/src/types/index.ts new file mode 100644 index 00000000000..14e5ccf6ec4 --- /dev/null +++ b/apps/cli/src/types/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js" +export * from "./constants.js" +export * from "./json-events.js" diff --git a/apps/cli/src/types/json-events.ts b/apps/cli/src/types/json-events.ts new file mode 100644 index 00000000000..73eb1b71506 --- /dev/null +++ b/apps/cli/src/types/json-events.ts @@ -0,0 +1,121 @@ +import { + rooCliOutputFormats, + type RooCliCost, + type RooCliEventType, + type RooCliFinalOutput, + type RooCliOutputFormat, + type RooCliQueueItem, + type RooCliStreamEvent, + type RooCliToolResult, + type RooCliToolUse, +} from "@roo-code/types" + +/** + * JSON Event Types for Structured CLI Output + * + * This module defines the types for structured JSON output from the CLI. + * The output format is NDJSON (newline-delimited JSON) for stream-json mode, + * or a single JSON object for json mode. + * + * Schema is optimized for efficiency with high message volume: + * - Minimal fields per event + * - No redundant wrappers + * - `done` flag instead of partial:false + */ + +/** + * Output format options for the CLI. + */ +export const OUTPUT_FORMATS = rooCliOutputFormats + +export type OutputFormat = RooCliOutputFormat + +export function isValidOutputFormat(format: string): format is OutputFormat { + return (OUTPUT_FORMATS as readonly string[]).includes(format) +} + +/** + * Event type discriminators for JSON output. + */ +export type JsonEventType = RooCliEventType + +export type JsonEventQueueItem = RooCliQueueItem + +/** + * Tool use information for tool_use events. + */ +export type JsonEventToolUse = RooCliToolUse + +/** + * Tool result information for tool_result events. + */ +export type JsonEventToolResult = RooCliToolResult + +/** + * Cost and token usage information. + */ +export type JsonEventCost = RooCliCost + +/** + * Base JSON event structure. + * Optimized for minimal payload size. + * + * For streaming deltas: + * - Each delta includes `id` for easy correlation + * - Final message has `done: true` + */ +export type JsonEvent = RooCliStreamEvent & { + /** Event type discriminator */ + type: JsonEventType + /** Protocol schema version (included on system.init) */ + schemaVersion?: number + /** Transport protocol identifier (included on system.init) */ + protocol?: string + /** Capability names supported by the current process */ + capabilities?: string[] + /** Message ID - included on first delta and final message */ + id?: number + /** Active task ID when available */ + taskId?: string + /** Request ID for correlating streamed output to stdin commands */ + requestId?: string + /** Command name for control events */ + command?: string + /** Content text (for text-based events) */ + content?: string + /** True when this is the final message (stream complete) */ + done?: boolean + /** Optional subtype for more specific categorization */ + subtype?: string + /** Optional machine-readable status/error code */ + code?: string + /** Current queue depth (for queue events) */ + queueDepth?: number + /** Queue item snapshots (for queue events) */ + queue?: JsonEventQueueItem[] + /** Tool use information (for tool_use events) */ + tool_use?: JsonEventToolUse + /** Tool result information (for tool_result events) */ + tool_result?: JsonEventToolResult + /** Whether the task succeeded (for result events) */ + success?: boolean + /** Cost and token usage (for result events) */ + cost?: JsonEventCost +} + +/** + * Final JSON output for "json" mode (single object at end). + * Contains the result and accumulated messages. + */ +export type JsonFinalOutput = RooCliFinalOutput & { + /** Final result type */ + type: "result" + /** Whether the task succeeded */ + success: boolean + /** Result content/message */ + content?: string + /** Cost and token usage */ + cost?: JsonEventCost + /** All events that occurred during the task */ + events: JsonEvent[] +} diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts new file mode 100644 index 00000000000..0a9f3d22597 --- /dev/null +++ b/apps/cli/src/types/types.ts @@ -0,0 +1,73 @@ +import type { ProviderName, ReasoningEffortExtended } from "@roo-code/types" +import type { OutputFormat } from "./json-events.js" + +export const supportedProviders = [ + "anthropic", + "openai-native", + "gemini", + "openrouter", + "vercel-ai-gateway", +] as const satisfies ProviderName[] + +export type SupportedProvider = (typeof supportedProviders)[number] + +export function isSupportedProvider(provider: string): provider is SupportedProvider { + return supportedProviders.includes(provider as SupportedProvider) +} + +export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified" | "disabled" + +export type FlagOptions = { + promptFile?: string + createWithSessionId?: string + sessionId?: string + continue: boolean + workspace?: string + print: boolean + stdinPromptStream: boolean + signalOnlyExit: boolean + extension?: string + debug: boolean + requireApproval: boolean + exitOnError: boolean + apiKey?: string + provider?: SupportedProvider + model?: string + mode?: string + terminalShell?: string + reasoningEffort?: ReasoningEffortFlagOptions + consecutiveMistakeLimit?: number + ephemeral: boolean + oneshot: boolean + outputFormat?: OutputFormat +} + +export enum OnboardingProviderChoice { + Byok = "byok", +} + +export interface OnboardingResult { + choice: OnboardingProviderChoice + token?: string + skipped: boolean +} + +export interface CliSettings { + onboardingProviderChoice?: OnboardingProviderChoice + /** Default mode to use (e.g., "code", "architect", "ask", "debug") */ + mode?: string + /** Default provider to use */ + provider?: SupportedProvider + /** Default model to use */ + model?: string + /** Default reasoning effort level */ + reasoningEffort?: ReasoningEffortFlagOptions + /** Default consecutive error/repetition limit before guidance prompts */ + consecutiveMistakeLimit?: number + /** Require manual approval for tools/commands/browser/MCP actions */ + requireApproval?: boolean + /** @deprecated Legacy inverse setting kept for backward compatibility */ + dangerouslySkipPermissions?: boolean + /** Exit upon task completion */ + oneshot?: boolean +} diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx new file mode 100644 index 00000000000..ede7c831705 --- /dev/null +++ b/apps/cli/src/ui/App.tsx @@ -0,0 +1,626 @@ +import { Box, Text, useApp, useInput } from "ink" +import { Select } from "@inkjs/ui" +import { useState, useEffect, useCallback, useRef, useMemo } from "react" + +import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js" + +import { getGlobalCommandsForAutocomplete } from "@/lib/utils/commands.js" +import { arePathsEqual } from "@/lib/utils/path.js" +import { getContextWindow } from "@/lib/utils/context-window.js" + +import * as theme from "./theme.js" +import { useCLIStore } from "./store.js" +import { useUIStateStore } from "./stores/uiStateStore.js" + +// Import extracted hooks. +import { + TerminalSizeProvider, + useTerminalSize, + useToast, + useExtensionHost, + useMessageHandlers, + useTaskSubmit, + useGlobalInput, + useFollowupCountdown, + useFocusManagement, + usePickerHandlers, +} from "./hooks/index.js" + +// Import extracted utilities. +import { getView } from "./utils/index.js" + +// Import components. +import Header from "./components/Header.js" +import ChatHistoryItem from "./components/ChatHistoryItem.js" +import LoadingText from "./components/LoadingText.js" +import ToastDisplay from "./components/ToastDisplay.js" +import TodoDisplay from "./components/TodoDisplay.js" +import { HorizontalLine } from "./components/HorizontalLine.js" +import { + type AutocompleteInputHandle, + type AutocompleteTrigger, + type FileResult, + type SlashCommandResult, + AutocompleteInput, + PickerSelect, + createFileTrigger, + createSlashCommandTrigger, + createModeTrigger, + createHelpTrigger, + createHistoryTrigger, + toFileResult, + toSlashCommandResult, + toModeResult, + toHistoryResult, +} from "./components/autocomplete/index.js" +import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js" +import ScrollIndicator from "./components/ScrollIndicator.js" + +const PICKER_HEIGHT = 10 + +export interface TUIAppProps extends ExtensionHostOptions { + initialPrompt?: string + initialTaskId?: string + initialSessionId?: string + continueSession?: boolean + version: string + // Create extension host factory for dependency injection. + createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface +} + +/** + * Inner App component that uses the terminal size context + */ +function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) { + const { + initialPrompt, + initialTaskId, + initialSessionId, + continueSession, + workspacePath, + extensionPath, + user, + provider, + apiKey, + model, + mode, + nonInteractive = false, + debug, + exitOnComplete, + reasoningEffort, + ephemeral, + version, + } = extensionHostOptions + + const { exit } = useApp() + + const { + messages, + pendingAsk, + isLoading, + isComplete, + hasStartedTask: _hasStartedTask, + error, + fileSearchResults, + allSlashCommands, + availableModes, + taskHistory, + currentMode, + tokenUsage, + routerModels, + apiConfiguration, + currentTodos, + } = useCLIStore() + + // Access UI state from the UI store + const { + showExitHint, + countdownSeconds, + showCustomInput, + isTransitioningToCustomInput, + showTodoViewer, + pickerState, + setIsTransitioningToCustomInput, + } = useUIStateStore() + + // Compute context window from router models and API configuration + const contextWindow = useMemo(() => { + return getContextWindow(routerModels, apiConfiguration) + }, [routerModels, apiConfiguration]) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const autocompleteRef = useRef>(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const followupAutocompleteRef = useRef>(null) + + // Stable refs for autocomplete data - prevents useMemo from recreating triggers on every data change + const fileSearchResultsRef = useRef(fileSearchResults) + const allSlashCommandsRef = useRef(allSlashCommands) + const availableModesRef = useRef(availableModes) + const taskHistoryRef = useRef(taskHistory) + + // Keep refs in sync with current state + useEffect(() => { + fileSearchResultsRef.current = fileSearchResults + }, [fileSearchResults]) + useEffect(() => { + allSlashCommandsRef.current = allSlashCommands + }, [allSlashCommands]) + useEffect(() => { + availableModesRef.current = availableModes + }, [availableModes]) + useEffect(() => { + taskHistoryRef.current = taskHistory + }, [taskHistory]) + + // Scroll area state + const { rows } = useTerminalSize() + const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true }) + const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom() + + // RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick). + const rafIdRef = useRef(null) + const pendingScrollRef = useRef<{ scrollTop: number; maxScroll: number; isAtBottom: boolean } | null>(null) + + // Toast notifications for ephemeral messages (e.g., mode changes). + const { currentToast, showInfo } = useToast() + + const { + handleExtensionMessage, + seenMessageIds, + pendingCommandRef: _pendingCommandRef, + firstTextMessageSkipped, + } = useMessageHandlers({ + nonInteractive, + }) + + const { sendToExtension, runTask, cleanup } = useExtensionHost({ + initialPrompt, + initialTaskId, + initialSessionId, + continueSession, + mode, + reasoningEffort, + user, + provider, + apiKey, + model, + workspacePath, + extensionPath, + debug, + nonInteractive, + ephemeral, + exitOnComplete, + onExtensionMessage: handleExtensionMessage, + createExtensionHost, + }) + + // Initialize task submit hook + const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({ + sendToExtension, + runTask, + seenMessageIds, + firstTextMessageSkipped, + }) + + // Initialize focus management hook + const { canToggleFocus, isScrollAreaActive, isInputAreaActive, toggleFocus } = useFocusManagement({ + showApprovalPrompt: Boolean(pendingAsk && pendingAsk.type !== "followup"), + pendingAsk, + }) + + // Initialize countdown hook for followup auto-accept + const { cancelCountdown } = useFollowupCountdown({ + pendingAsk, + onAutoSubmit: handleSubmit, + }) + + // Initialize picker handlers hook + const { handlePickerStateChange, handlePickerSelect, handlePickerClose, handlePickerIndexChange } = + usePickerHandlers({ + autocompleteRef, + followupAutocompleteRef, + sendToExtension, + showInfo, + seenMessageIds, + firstTextMessageSkipped, + }) + + // Initialize global input hook + useGlobalInput({ + canToggleFocus, + isScrollAreaActive, + pickerIsOpen: pickerState.isOpen, + availableModes, + currentMode, + mode, + sendToExtension, + showInfo, + exit, + cleanup, + toggleFocus, + closePicker: handlePickerClose, + }) + + // Determine current view + const view = getView(messages, pendingAsk, isLoading) + + // Determine if we should show the approval prompt (Y/N) instead of text input + const showApprovalPrompt = pendingAsk && pendingAsk.type !== "followup" + + // Display all messages including partial (streaming) ones + const displayMessages = useMemo(() => { + return messages + }, [messages]) + + // Scroll to bottom when new messages arrive (if auto-scroll is enabled) + const prevMessageCount = useRef(messages.length) + useEffect(() => { + if (messages.length > prevMessageCount.current && scrollState.isAtBottom) { + scrollToBottom() + } + prevMessageCount.current = messages.length + }, [messages.length, scrollState.isAtBottom, scrollToBottom]) + + // Handle scroll state changes from ScrollArea (RAF-throttled to coalesce rapid updates) + const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => { + // Store the latest scroll values in ref + pendingScrollRef.current = { scrollTop, maxScroll, isAtBottom } + + // Only schedule one update per event loop tick + if (rafIdRef.current === null) { + rafIdRef.current = setImmediate(() => { + rafIdRef.current = null + const pending = pendingScrollRef.current + if (pending) { + setScrollState(pending) + pendingScrollRef.current = null + } + }) + } + }, []) + + // Cleanup RAF-style timer on unmount + useEffect(() => { + return () => { + if (rafIdRef.current !== null) { + clearImmediate(rafIdRef.current) + } + } + }, []) + + // File search handler for the file trigger + const handleFileSearch = useCallback( + (query: string) => { + if (!sendToExtension) { + return + } + sendToExtension({ type: "searchFiles", query }) + }, + [sendToExtension], + ) + + // Create autocomplete triggers + // Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult, HistoryResult) + // IMPORTANT: We use refs here to avoid recreating triggers every time data changes. + // This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state + // The getResults/getCommands/getModes/getHistory callbacks always read from refs to get fresh data. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const autocompleteTriggers = useMemo((): AutocompleteTrigger[] => { + const fileTrigger = createFileTrigger({ + onSearch: handleFileSearch, + getResults: () => { + const results = fileSearchResultsRef.current + return results.map(toFileResult) + }, + }) + + const slashCommandTrigger = createSlashCommandTrigger({ + getCommands: () => { + // Merge CLI global commands with extension commands + const extensionCommands = allSlashCommandsRef.current.map(toSlashCommandResult) + const globalCommands = getGlobalCommandsForAutocomplete().map(toSlashCommandResult) + // Global commands appear first, then extension commands + return [...globalCommands, ...extensionCommands] + }, + }) + + const modeTrigger = createModeTrigger({ + getModes: () => availableModesRef.current.map(toModeResult), + }) + + const helpTrigger = createHelpTrigger() + + // History trigger - type # to search and resume previous tasks + const historyTrigger = createHistoryTrigger({ + getHistory: () => { + // Filter to only show tasks for the current workspace + // Use arePathsEqual for proper cross-platform path comparison + // (handles trailing slashes, separators, and case sensitivity) + const history = taskHistoryRef.current + const filtered = history.filter((item) => arePathsEqual(item.workspace, workspacePath)) + return filtered.map(toHistoryResult) + }, + }) + + return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger, historyTrigger] + }, [handleFileSearch, workspacePath]) // Only depend on handleFileSearch and workspacePath - data accessed via refs + + // Refresh search results when fileSearchResults changes while file picker is open + // This handles the async timing where API results arrive after initial search + // IMPORTANT: Only run when fileSearchResults array identity changes (new API response) + // We use a ref to track this and avoid depending on pickerState in the effect + const prevFileSearchResultsRef = useRef(fileSearchResults) + const pickerStateRef = useRef(pickerState) + pickerStateRef.current = pickerState + + useEffect(() => { + // Only run if fileSearchResults actually changed (different array reference) + if (fileSearchResults === prevFileSearchResultsRef.current) { + return + } + + const currentPickerState = pickerStateRef.current + const willRefresh = + currentPickerState.isOpen && currentPickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0 + + prevFileSearchResultsRef.current = fileSearchResults + + // Only refresh when file picker is open and we have new results + if (willRefresh) { + autocompleteRef.current?.refreshSearch() + followupAutocompleteRef.current?.refreshSearch() + } + }, [fileSearchResults]) // Only depend on fileSearchResults - read pickerState from ref + + // Handle Y/N input for approval prompts + useInput((input) => { + if (pendingAsk && pendingAsk.type !== "followup") { + const lower = input.toLowerCase() + + if (lower === "y") { + handleApprove() + } else if (lower === "n") { + handleReject() + } + } + }) + + // Cancel countdown timer when user navigates in the followup suggestion menu + // This provides better UX - any user interaction cancels the auto-accept timer + const showFollowupSuggestions = + pendingAsk?.type === "followup" && + pendingAsk.suggestions && + pendingAsk.suggestions.length > 0 && + !showCustomInput + + useInput((_input, key) => { + // Only handle when followup suggestions are shown and countdown is active + if (showFollowupSuggestions && countdownSeconds !== null) { + // Cancel countdown on any arrow key navigation + if (key.upArrow || key.downArrow) { + cancelCountdown() + } + } + }) + + // Error display + if (error) { + return ( + + + Error: {error} + + + Press Ctrl+C to exit + + + ) + } + + // Status bar content + // Priority: Toast > Exit hint > Loading > Scroll indicator > Input hint + // Don't show spinner when waiting for user input (pendingAsk is set) + const statusBarMessage = currentToast ? ( + + ) : showExitHint ? ( + Press Ctrl+C again to exit + ) : isLoading && !pendingAsk ? ( + + {view === "ToolUse" ? "Using tool" : "Thinking"} + + Esc to cancel + {isScrollAreaActive && ( + <> + + + + )} + + ) : isScrollAreaActive ? ( + + ) : isInputAreaActive ? ( + ? for shortcuts + ) : null + + const getPickerRenderItem = () => { + if (pickerState.activeTrigger) { + return pickerState.activeTrigger.renderItem + } + + return (item: FileResult | SlashCommandResult, isSelected: boolean) => ( + + {item.key} + + ) + } + + return ( + + {/* Header - fixed size */} + +
+ + + {/* Scrollable message history area - fills remaining space via flexGrow */} + + {displayMessages.map((message) => ( + + ))} + + + {/* Input area - with borders like Claude Code - fixed size */} + + {pendingAsk?.type === "followup" ? ( + + {pendingAsk.content} + {pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? ( + + + +
+ {searchBarShortcut && + searchBarShortcutHint && + (inputValue !== "" ? ( + + ) : ( + isBrowser && ( +
+ {isMac ? "⌘" : "ctrl"} + K +
+ ) + ))} +
+ ) +} diff --git a/apps/docs/src/theme/SearchPage/index.tsx b/apps/docs/src/theme/SearchPage/index.tsx new file mode 100644 index 00000000000..eed518ff075 --- /dev/null +++ b/apps/docs/src/theme/SearchPage/index.tsx @@ -0,0 +1,268 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* Custom SearchPage override that always pushes Release Notes results to the bottom */ +import React, { useCallback, useEffect, useMemo, useState } from "react" +import useDocusaurusContext from "@docusaurus/useDocusaurusContext" +import Layout from "@theme/Layout" +import Head from "@docusaurus/Head" +import Link from "@docusaurus/Link" +import { translate } from "@docusaurus/Translate" +import { usePluralForm } from "@docusaurus/theme-common" +import clsx from "clsx" + +// Plugin internals (mirrors original SearchPage) +import useSearchQuery from "@easyops-cn/docusaurus-search-local/dist/client/client/theme/hooks/useSearchQuery" +import { + fetchIndexesByWorker, + searchByWorker, +} from "@easyops-cn/docusaurus-search-local/dist/client/client/theme/searchByWorker" +import { SearchDocumentType } from "@easyops-cn/docusaurus-search-local/dist/client/shared/interfaces" +import { highlight } from "@easyops-cn/docusaurus-search-local/dist/client/client/utils/highlight" +import { highlightStemmed } from "@easyops-cn/docusaurus-search-local/dist/client/client/utils/highlightStemmed" +import { getStemmedPositions } from "@easyops-cn/docusaurus-search-local/dist/client/client/utils/getStemmedPositions" +import LoadingRing from "@easyops-cn/docusaurus-search-local/dist/client/client/theme/LoadingRing/LoadingRing" +import { concatDocumentPath } from "@easyops-cn/docusaurus-search-local/dist/client/client/utils/concatDocumentPath" +import { + Mark, + searchContextByPaths, + useAllContextsWithNoSearchContext, +} from "@easyops-cn/docusaurus-search-local/dist/client/client/utils/proxiedGenerated" +import styles from "@easyops-cn/docusaurus-search-local/dist/client/client/theme/SearchPage/SearchPage.module.css" +import { normalizeContextByPath } from "@easyops-cn/docusaurus-search-local/dist/client/client/utils/normalizeContextByPath" + +// Ensure release notes always sink to bottom +function deprioritizeReleaseNotes(results: any[]) { + if (!Array.isArray(results)) return results + const isRN = (u: string) => typeof u === "string" && u.includes("/update-notes") + const nonRN: any[] = [] + const rn: any[] = [] + for (const r of results) { + const url = r?.document?.u ?? "" + ;(isRN(url) ? rn : nonRN).push(r) + } + return [...nonRN, ...rn] +} + +export default function SearchPage() { + return ( + + + + ) +} + +function SearchPageContent() { + const { + siteConfig: { baseUrl }, + i18n: { currentLocale }, + } = useDocusaurusContext() + const { selectMessage } = usePluralForm() + const { searchValue, searchContext, searchVersion, updateSearchPath, updateSearchContext } = useSearchQuery() as any + + const [searchQuery, setSearchQuery] = useState(searchValue) + const [searchResults, setSearchResults] = useState() + const versionUrl = `${baseUrl}${searchVersion}` + const pageTitle = useMemo( + () => + searchQuery + ? translate( + { + id: "theme.SearchPage.existingResultsTitle", + message: 'Search results for "{query}"', + description: "The search page title for non-empty query", + }, + { + query: searchQuery, + }, + ) + : translate({ + id: "theme.SearchPage.emptyResultsTitle", + message: "Search the documentation", + description: "The search page title for empty query", + }), + [searchQuery], + ) + + useEffect(() => { + updateSearchPath(searchQuery) + if (searchQuery) { + ;(async () => { + const results = await searchByWorker(versionUrl, searchContext, searchQuery, 100) + setSearchResults(deprioritizeReleaseNotes(results)) + })() + } else { + setSearchResults(undefined) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- updateSearchPath intentionally omitted (matches plugin behavior) + }, [searchQuery, versionUrl, searchContext]) + + const handleSearchInputChange = useCallback((e: React.ChangeEvent) => { + setSearchQuery(e.target.value) + }, []) + + useEffect(() => { + if (searchValue && searchValue !== searchQuery) { + setSearchQuery(searchValue) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- searchQuery intentionally omitted to prevent loops + }, [searchValue]) + + const [searchWorkerReady, setSearchWorkerReady] = useState(false) + useEffect(() => { + async function doFetchIndexes() { + if (!Array.isArray(searchContextByPaths) || searchContext || useAllContextsWithNoSearchContext) { + await fetchIndexesByWorker(versionUrl, searchContext) + } + setSearchWorkerReady(true) + } + doFetchIndexes() + }, [searchContext, versionUrl]) + + return ( + + + {/* Do not index search pages */} + + {pageTitle} + + +
+

{pageTitle}

+ +
+
+ +
+ {Array.isArray(searchContextByPaths) ? ( +
+ +
+ ) : null} +
+ + {!searchWorkerReady && searchQuery && ( +
+ +
+ )} + + {searchResults && + (searchResults.length > 0 ? ( +

+ {selectMessage( + searchResults.length, + translate( + { + id: "theme.SearchPage.documentsFound.plurals", + message: "1 document found|{count} documents found", + description: + 'Pluralized label for "{count} documents found". See https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html', + }, + { count: searchResults.length }, + ), + )} +

+ ) : process.env.NODE_ENV === "production" ? ( +

+ {translate({ + id: "theme.SearchPage.noResultsText", + message: "No documents were found", + description: "The paragraph for empty search result", + })} +

+ ) : ( +

⚠️ The search index is only available when you run docusaurus build!

+ ))} + +
+ {searchResults?.map((item) => )} +
+
+
+ ) +} + +function SearchResultItem({ searchResult: { document, type, page, tokens, metadata } }: { searchResult: any }) { + const isTitle = type === SearchDocumentType.Title + const isKeywords = type === SearchDocumentType.Keywords + const isDescription = type === SearchDocumentType.Description + const isDescriptionOrKeywords = isDescription || isKeywords + const isTitleRelated = isTitle || isDescriptionOrKeywords + const isContent = type === SearchDocumentType.Content + + const pathItems = (isTitle ? document.b : page.b).slice() + const articleTitle = isContent || isDescriptionOrKeywords ? document.s : document.t + if (!isTitleRelated) { + pathItems.push(page.t) + } + + let search = "" + if (Mark && tokens.length > 0) { + const params = new window.URLSearchParams() + for (const token of tokens) { + params.append("_highlight", token) + } + search = `?${params.toString()}` + } + + return ( +
+

+ +

+ + {pathItems.length > 0 &&

{concatDocumentPath(pathItems)}

} + + {(isContent || isDescription) && ( +

+ )} +

+ ) +} diff --git a/apps/docs/src/types/react-inert.d.ts b/apps/docs/src/types/react-inert.d.ts new file mode 100644 index 00000000000..1054211da38 --- /dev/null +++ b/apps/docs/src/types/react-inert.d.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import "react" + +declare module "react" { + interface HTMLAttributes { + inert?: boolean + } +} + +declare global { + namespace React { + interface HTMLAttributes { + inert?: boolean + } + } +} diff --git a/apps/docs/static/downloads/boomerang-tasks/roomodes.json b/apps/docs/static/downloads/boomerang-tasks/roomodes.json new file mode 100644 index 00000000000..c4aa8f39b28 --- /dev/null +++ b/apps/docs/static/downloads/boomerang-tasks/roomodes.json @@ -0,0 +1,12 @@ +{ + "customModes": [ + { + "slug": "boomerang-mode", + "name": "Boomerang Mode", + "roleDefinition": "You are Roo, a strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized modes. You have a comprehensive understanding of each mode's capabilities and limitations, allowing you to effectively break down complex problems into discrete tasks that can be solved by different specialists.", + "customInstructions": "Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask's specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project. \n * A statement that these specific instructions supersede any conflicting general instructions the subtask's mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you're delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.", + "groups": [], + "source": "global" + } + ] +} diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-1.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-1.png new file mode 100644 index 00000000000..4896bbf8909 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-1.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-10.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-10.png new file mode 100644 index 00000000000..57d8f98d16a Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-10.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-11.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-11.png new file mode 100644 index 00000000000..246e2dc1902 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-11.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-12.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-12.png new file mode 100644 index 00000000000..c5ef1e97607 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-12.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-2.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-2.png new file mode 100644 index 00000000000..b3183e7bed8 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-2.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-3.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-3.png new file mode 100644 index 00000000000..50acf92cdc3 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-3.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-4.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-4.png new file mode 100644 index 00000000000..a699ad914b7 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-4.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-5.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-5.png new file mode 100644 index 00000000000..c55abe2094f Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-5.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-6.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-6.png new file mode 100644 index 00000000000..188947a5ea1 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-6.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-7.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-7.png new file mode 100644 index 00000000000..0ad8b083c4e Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-7.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-8.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-8.png new file mode 100644 index 00000000000..a93f7db6ec9 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-8.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-9.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-9.png new file mode 100644 index 00000000000..110e26978e2 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles-9.png differ diff --git a/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles.png b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles.png new file mode 100644 index 00000000000..5afdef80256 Binary files /dev/null and b/apps/docs/static/img/api-configuration-profiles/api-configuration-profiles.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-1.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-1.png new file mode 100644 index 00000000000..a10c0fce509 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-1.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-10.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-10.png new file mode 100644 index 00000000000..75d8b2834e1 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-10.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-11.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-11.png new file mode 100644 index 00000000000..0b4e3ad7315 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-11.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-12.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-12.png new file mode 100644 index 00000000000..b00c8b442cb Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-12.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-13.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-13.png new file mode 100644 index 00000000000..d7afb227ad2 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-13.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-14.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-14.png new file mode 100644 index 00000000000..06598699385 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-14.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-15.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-15.png new file mode 100644 index 00000000000..1daca635bb7 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-15.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-16.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-16.png new file mode 100644 index 00000000000..021888ca6ad Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-16.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-18.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-18.png new file mode 100644 index 00000000000..5166138cda1 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-18.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-19.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-19.png new file mode 100644 index 00000000000..096f37a7d50 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-19.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-2.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-2.png new file mode 100644 index 00000000000..7b0661762cb Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-2.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-20.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-20.png new file mode 100644 index 00000000000..59d3b311f8f Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-20.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-21.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-21.png new file mode 100644 index 00000000000..c2c02e65e3f Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-21.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-3.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-3.png new file mode 100644 index 00000000000..00a9b9d8f3c Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-3.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-4.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-4.png new file mode 100644 index 00000000000..795e6d4ec85 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-4.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-5.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-5.png new file mode 100644 index 00000000000..67a78e503ed Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-5.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-6.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-6.png new file mode 100644 index 00000000000..428b3963671 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-6.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-7.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-7.png new file mode 100644 index 00000000000..b99d3a12181 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-7.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-8.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-8.png new file mode 100644 index 00000000000..d495e474ce9 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-8.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions-9.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-9.png new file mode 100644 index 00000000000..f5650cff7a2 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions-9.png differ diff --git a/apps/docs/static/img/auto-approving-actions/auto-approving-actions.png b/apps/docs/static/img/auto-approving-actions/auto-approving-actions.png new file mode 100644 index 00000000000..ea4a7a426a0 Binary files /dev/null and b/apps/docs/static/img/auto-approving-actions/auto-approving-actions.png differ diff --git a/apps/docs/static/img/available-tools/available-tools.png b/apps/docs/static/img/available-tools/available-tools.png new file mode 100644 index 00000000000..ef90e554a8a Binary files /dev/null and b/apps/docs/static/img/available-tools/available-tools.png differ diff --git a/apps/docs/static/img/background-editing/background-editing.png b/apps/docs/static/img/background-editing/background-editing.png new file mode 100644 index 00000000000..47cd38d7236 Binary files /dev/null and b/apps/docs/static/img/background-editing/background-editing.png differ diff --git a/apps/docs/static/img/browser-use/browser-use-1.png b/apps/docs/static/img/browser-use/browser-use-1.png new file mode 100644 index 00000000000..0bd708fa12e Binary files /dev/null and b/apps/docs/static/img/browser-use/browser-use-1.png differ diff --git a/apps/docs/static/img/browser-use/browser-use-2.png b/apps/docs/static/img/browser-use/browser-use-2.png new file mode 100644 index 00000000000..9c3971bf6ca Binary files /dev/null and b/apps/docs/static/img/browser-use/browser-use-2.png differ diff --git a/apps/docs/static/img/browser-use/browser-use-3.png b/apps/docs/static/img/browser-use/browser-use-3.png new file mode 100644 index 00000000000..fca7d812d5d Binary files /dev/null and b/apps/docs/static/img/browser-use/browser-use-3.png differ diff --git a/apps/docs/static/img/browser-use/browser-use-4.png b/apps/docs/static/img/browser-use/browser-use-4.png new file mode 100644 index 00000000000..4fd485168c0 Binary files /dev/null and b/apps/docs/static/img/browser-use/browser-use-4.png differ diff --git a/apps/docs/static/img/browser-use/browser-use-5.png b/apps/docs/static/img/browser-use/browser-use-5.png new file mode 100644 index 00000000000..2a412168034 Binary files /dev/null and b/apps/docs/static/img/browser-use/browser-use-5.png differ diff --git a/apps/docs/static/img/browser-use/browser-use.png b/apps/docs/static/img/browser-use/browser-use.png new file mode 100644 index 00000000000..2861a97a043 Binary files /dev/null and b/apps/docs/static/img/browser-use/browser-use.png differ diff --git a/apps/docs/static/img/checkpoints/checkpoints-1.png b/apps/docs/static/img/checkpoints/checkpoints-1.png new file mode 100644 index 00000000000..4acd43178e5 Binary files /dev/null and b/apps/docs/static/img/checkpoints/checkpoints-1.png differ diff --git a/apps/docs/static/img/checkpoints/checkpoints-2.png b/apps/docs/static/img/checkpoints/checkpoints-2.png new file mode 100644 index 00000000000..63719a10f6c Binary files /dev/null and b/apps/docs/static/img/checkpoints/checkpoints-2.png differ diff --git a/apps/docs/static/img/checkpoints/checkpoints-3.png b/apps/docs/static/img/checkpoints/checkpoints-3.png new file mode 100644 index 00000000000..e212b6190ab Binary files /dev/null and b/apps/docs/static/img/checkpoints/checkpoints-3.png differ diff --git a/apps/docs/static/img/checkpoints/checkpoints-4.png b/apps/docs/static/img/checkpoints/checkpoints-4.png new file mode 100644 index 00000000000..7c4fc3e78b8 Binary files /dev/null and b/apps/docs/static/img/checkpoints/checkpoints-4.png differ diff --git a/apps/docs/static/img/checkpoints/checkpoints-6.png b/apps/docs/static/img/checkpoints/checkpoints-6.png new file mode 100644 index 00000000000..3dd7ccb9c7f Binary files /dev/null and b/apps/docs/static/img/checkpoints/checkpoints-6.png differ diff --git a/apps/docs/static/img/checkpoints/checkpoints-7.png b/apps/docs/static/img/checkpoints/checkpoints-7.png new file mode 100644 index 00000000000..e9dc5ac45a0 Binary files /dev/null and b/apps/docs/static/img/checkpoints/checkpoints-7.png differ diff --git a/apps/docs/static/img/checkpoints/checkpoints-9.png b/apps/docs/static/img/checkpoints/checkpoints-9.png new file mode 100644 index 00000000000..b53afd33751 Binary files /dev/null and b/apps/docs/static/img/checkpoints/checkpoints-9.png differ diff --git a/apps/docs/static/img/checkpoints/checkpoints.png b/apps/docs/static/img/checkpoints/checkpoints.png new file mode 100644 index 00000000000..c9b7d65e43b Binary files /dev/null and b/apps/docs/static/img/checkpoints/checkpoints.png differ diff --git a/apps/docs/static/img/codebase-indexing/codebase-indexing-1.png b/apps/docs/static/img/codebase-indexing/codebase-indexing-1.png new file mode 100644 index 00000000000..1b2fa6478ed Binary files /dev/null and b/apps/docs/static/img/codebase-indexing/codebase-indexing-1.png differ diff --git a/apps/docs/static/img/codebase-indexing/codebase-indexing-2.png b/apps/docs/static/img/codebase-indexing/codebase-indexing-2.png new file mode 100644 index 00000000000..ba40a69ef04 Binary files /dev/null and b/apps/docs/static/img/codebase-indexing/codebase-indexing-2.png differ diff --git a/apps/docs/static/img/codebase-indexing/codebase-indexing-3.png b/apps/docs/static/img/codebase-indexing/codebase-indexing-3.png new file mode 100644 index 00000000000..85393915e66 Binary files /dev/null and b/apps/docs/static/img/codebase-indexing/codebase-indexing-3.png differ diff --git a/apps/docs/static/img/codebase-indexing/codebase-indexing.png b/apps/docs/static/img/codebase-indexing/codebase-indexing.png new file mode 100644 index 00000000000..9b18c982d89 Binary files /dev/null and b/apps/docs/static/img/codebase-indexing/codebase-indexing.png differ diff --git a/apps/docs/static/img/concurrent-file-edits/concurrent-file-edits-1.png b/apps/docs/static/img/concurrent-file-edits/concurrent-file-edits-1.png new file mode 100644 index 00000000000..46402323d20 Binary files /dev/null and b/apps/docs/static/img/concurrent-file-edits/concurrent-file-edits-1.png differ diff --git a/apps/docs/static/img/concurrent-file-edits/concurrent-file-edits.png b/apps/docs/static/img/concurrent-file-edits/concurrent-file-edits.png new file mode 100644 index 00000000000..59c7a4dffba Binary files /dev/null and b/apps/docs/static/img/concurrent-file-edits/concurrent-file-edits.png differ diff --git a/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads-1.png b/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads-1.png new file mode 100644 index 00000000000..4d0fbdfff46 Binary files /dev/null and b/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads-1.png differ diff --git a/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads-2.png b/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads-2.png new file mode 100644 index 00000000000..3fcb07530e2 Binary files /dev/null and b/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads-2.png differ diff --git a/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads.png b/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads.png new file mode 100644 index 00000000000..19b08dd514d Binary files /dev/null and b/apps/docs/static/img/concurrent-file-reads/concurrent-file-reads.png differ diff --git a/apps/docs/static/img/connecting-api-provider/connecting-api-provider-4.png b/apps/docs/static/img/connecting-api-provider/connecting-api-provider-4.png new file mode 100644 index 00000000000..a60622d16de Binary files /dev/null and b/apps/docs/static/img/connecting-api-provider/connecting-api-provider-4.png differ diff --git a/apps/docs/static/img/connecting-api-provider/connecting-api-provider-5.png b/apps/docs/static/img/connecting-api-provider/connecting-api-provider-5.png new file mode 100644 index 00000000000..09f170a8626 Binary files /dev/null and b/apps/docs/static/img/connecting-api-provider/connecting-api-provider-5.png differ diff --git a/apps/docs/static/img/connecting-api-provider/connecting-api-provider-6.png b/apps/docs/static/img/connecting-api-provider/connecting-api-provider-6.png new file mode 100644 index 00000000000..8ac7e530323 Binary files /dev/null and b/apps/docs/static/img/connecting-api-provider/connecting-api-provider-6.png differ diff --git a/apps/docs/static/img/connecting-api-provider/connecting-api-provider-7.png b/apps/docs/static/img/connecting-api-provider/connecting-api-provider-7.png new file mode 100644 index 00000000000..62fdfbb8498 Binary files /dev/null and b/apps/docs/static/img/connecting-api-provider/connecting-api-provider-7.png differ diff --git a/apps/docs/static/img/context-mentions/context-mentions-1.png b/apps/docs/static/img/context-mentions/context-mentions-1.png new file mode 100644 index 00000000000..9f8bdc4beb3 Binary files /dev/null and b/apps/docs/static/img/context-mentions/context-mentions-1.png differ diff --git a/apps/docs/static/img/context-mentions/context-mentions-2.png b/apps/docs/static/img/context-mentions/context-mentions-2.png new file mode 100644 index 00000000000..90ecbda70b0 Binary files /dev/null and b/apps/docs/static/img/context-mentions/context-mentions-2.png differ diff --git a/apps/docs/static/img/context-mentions/context-mentions-3.png b/apps/docs/static/img/context-mentions/context-mentions-3.png new file mode 100644 index 00000000000..20b2bfea461 Binary files /dev/null and b/apps/docs/static/img/context-mentions/context-mentions-3.png differ diff --git a/apps/docs/static/img/context-mentions/context-mentions-4.png b/apps/docs/static/img/context-mentions/context-mentions-4.png new file mode 100644 index 00000000000..321c750105f Binary files /dev/null and b/apps/docs/static/img/context-mentions/context-mentions-4.png differ diff --git a/apps/docs/static/img/context-mentions/context-mentions-5.png b/apps/docs/static/img/context-mentions/context-mentions-5.png new file mode 100644 index 00000000000..bd0367d149f Binary files /dev/null and b/apps/docs/static/img/context-mentions/context-mentions-5.png differ diff --git a/apps/docs/static/img/context-mentions/context-mentions-6.png b/apps/docs/static/img/context-mentions/context-mentions-6.png new file mode 100644 index 00000000000..e933613b000 Binary files /dev/null and b/apps/docs/static/img/context-mentions/context-mentions-6.png differ diff --git a/apps/docs/static/img/context-mentions/context-mentions.png b/apps/docs/static/img/context-mentions/context-mentions.png new file mode 100644 index 00000000000..30afb6ccb83 Binary files /dev/null and b/apps/docs/static/img/context-mentions/context-mentions.png differ diff --git a/apps/docs/static/img/custom-instructions/custom-instructions-2.png b/apps/docs/static/img/custom-instructions/custom-instructions-2.png new file mode 100644 index 00000000000..6c92f3dedd1 Binary files /dev/null and b/apps/docs/static/img/custom-instructions/custom-instructions-2.png differ diff --git a/apps/docs/static/img/custom-instructions/custom-instructions.png b/apps/docs/static/img/custom-instructions/custom-instructions.png new file mode 100644 index 00000000000..16feb1c328f Binary files /dev/null and b/apps/docs/static/img/custom-instructions/custom-instructions.png differ diff --git a/apps/docs/static/img/custom-modes/custom-modes-1.png b/apps/docs/static/img/custom-modes/custom-modes-1.png new file mode 100644 index 00000000000..c9ad7a989a4 Binary files /dev/null and b/apps/docs/static/img/custom-modes/custom-modes-1.png differ diff --git a/apps/docs/static/img/custom-modes/custom-modes-2.png b/apps/docs/static/img/custom-modes/custom-modes-2.png new file mode 100644 index 00000000000..8563536accd Binary files /dev/null and b/apps/docs/static/img/custom-modes/custom-modes-2.png differ diff --git a/apps/docs/static/img/custom-modes/custom-modes-3.png b/apps/docs/static/img/custom-modes/custom-modes-3.png new file mode 100644 index 00000000000..03f73061602 Binary files /dev/null and b/apps/docs/static/img/custom-modes/custom-modes-3.png differ diff --git a/apps/docs/static/img/custom-modes/custom-modes-4.png b/apps/docs/static/img/custom-modes/custom-modes-4.png new file mode 100644 index 00000000000..b274e59d7a2 Binary files /dev/null and b/apps/docs/static/img/custom-modes/custom-modes-4.png differ diff --git a/apps/docs/static/img/custom-modes/custom-modes-5.png b/apps/docs/static/img/custom-modes/custom-modes-5.png new file mode 100644 index 00000000000..fcdb067b4fc Binary files /dev/null and b/apps/docs/static/img/custom-modes/custom-modes-5.png differ diff --git a/apps/docs/static/img/custom-modes/custom-modes-6.png b/apps/docs/static/img/custom-modes/custom-modes-6.png new file mode 100644 index 00000000000..69b4221c21b Binary files /dev/null and b/apps/docs/static/img/custom-modes/custom-modes-6.png differ diff --git a/apps/docs/static/img/custom-modes/custom-modes.png b/apps/docs/static/img/custom-modes/custom-modes.png new file mode 100644 index 00000000000..18ba84c356c Binary files /dev/null and b/apps/docs/static/img/custom-modes/custom-modes.png differ diff --git a/apps/docs/static/img/custom-tools/custom-tools.png b/apps/docs/static/img/custom-tools/custom-tools.png new file mode 100644 index 00000000000..d696c64b1b9 Binary files /dev/null and b/apps/docs/static/img/custom-tools/custom-tools.png differ diff --git a/apps/docs/static/img/experimental-features/experimental-features.png b/apps/docs/static/img/experimental-features/experimental-features.png new file mode 100644 index 00000000000..d6bf53d74ce Binary files /dev/null and b/apps/docs/static/img/experimental-features/experimental-features.png differ diff --git a/apps/docs/static/img/fast-edits/fast-edits-1.png b/apps/docs/static/img/fast-edits/fast-edits-1.png new file mode 100644 index 00000000000..dc98d356779 Binary files /dev/null and b/apps/docs/static/img/fast-edits/fast-edits-1.png differ diff --git a/apps/docs/static/img/fast-edits/fast-edits-2.png b/apps/docs/static/img/fast-edits/fast-edits-2.png new file mode 100644 index 00000000000..ec569038d99 Binary files /dev/null and b/apps/docs/static/img/fast-edits/fast-edits-2.png differ diff --git a/apps/docs/static/img/fast-edits/fast-edits-3.png b/apps/docs/static/img/fast-edits/fast-edits-3.png new file mode 100644 index 00000000000..ae5842a0162 Binary files /dev/null and b/apps/docs/static/img/fast-edits/fast-edits-3.png differ diff --git a/apps/docs/static/img/fast-edits/fast-edits-4.png b/apps/docs/static/img/fast-edits/fast-edits-4.png new file mode 100644 index 00000000000..b6b26784f36 Binary files /dev/null and b/apps/docs/static/img/fast-edits/fast-edits-4.png differ diff --git a/apps/docs/static/img/fast-edits/fast-edits-5.png b/apps/docs/static/img/fast-edits/fast-edits-5.png new file mode 100644 index 00000000000..c465fd6dbce Binary files /dev/null and b/apps/docs/static/img/fast-edits/fast-edits-5.png differ diff --git a/apps/docs/static/img/fast-edits/fast-edits.png b/apps/docs/static/img/fast-edits/fast-edits.png new file mode 100644 index 00000000000..036beb4d3bb Binary files /dev/null and b/apps/docs/static/img/fast-edits/fast-edits.png differ diff --git a/apps/web-roo-code/public/favicon.ico b/apps/docs/static/img/favicon.ico similarity index 100% rename from apps/web-roo-code/public/favicon.ico rename to apps/docs/static/img/favicon.ico diff --git a/apps/docs/static/img/footgun-prompting/footgun-prompting-1.png b/apps/docs/static/img/footgun-prompting/footgun-prompting-1.png new file mode 100644 index 00000000000..359c9b487d6 Binary files /dev/null and b/apps/docs/static/img/footgun-prompting/footgun-prompting-1.png differ diff --git a/apps/docs/static/img/footgun-prompting/footgun-prompting.png b/apps/docs/static/img/footgun-prompting/footgun-prompting.png new file mode 100644 index 00000000000..0f19fa5c268 Binary files /dev/null and b/apps/docs/static/img/footgun-prompting/footgun-prompting.png differ diff --git a/apps/docs/static/img/how-tools-work/how-tools-work.png b/apps/docs/static/img/how-tools-work/how-tools-work.png new file mode 100644 index 00000000000..4227e61da09 Binary files /dev/null and b/apps/docs/static/img/how-tools-work/how-tools-work.png differ diff --git a/apps/docs/static/img/installing/installing-1.png b/apps/docs/static/img/installing/installing-1.png new file mode 100644 index 00000000000..d2baafe79ff Binary files /dev/null and b/apps/docs/static/img/installing/installing-1.png differ diff --git a/apps/docs/static/img/installing/installing-2.png b/apps/docs/static/img/installing/installing-2.png new file mode 100644 index 00000000000..47a2e7f1bbc Binary files /dev/null and b/apps/docs/static/img/installing/installing-2.png differ diff --git a/apps/docs/static/img/installing/installing-3.png b/apps/docs/static/img/installing/installing-3.png new file mode 100644 index 00000000000..679fe7c1379 Binary files /dev/null and b/apps/docs/static/img/installing/installing-3.png differ diff --git a/apps/docs/static/img/installing/installing-4.png b/apps/docs/static/img/installing/installing-4.png new file mode 100644 index 00000000000..f2870524db7 Binary files /dev/null and b/apps/docs/static/img/installing/installing-4.png differ diff --git a/apps/docs/static/img/installing/installing-5.png b/apps/docs/static/img/installing/installing-5.png new file mode 100644 index 00000000000..237e8392af7 Binary files /dev/null and b/apps/docs/static/img/installing/installing-5.png differ diff --git a/apps/docs/static/img/installing/installing.png b/apps/docs/static/img/installing/installing.png new file mode 100644 index 00000000000..5e604e16795 Binary files /dev/null and b/apps/docs/static/img/installing/installing.png differ diff --git a/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-1.png b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-1.png new file mode 100644 index 00000000000..a03f4cd2d64 Binary files /dev/null and b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-1.png differ diff --git a/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-2.png b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-2.png new file mode 100644 index 00000000000..b13d7f5d7b1 Binary files /dev/null and b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-2.png differ diff --git a/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-3.png b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-3.png new file mode 100644 index 00000000000..01a88ec391a Binary files /dev/null and b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-3.png differ diff --git a/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-4.png b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-4.png new file mode 100644 index 00000000000..47df6416a6a Binary files /dev/null and b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation-4.png differ diff --git a/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation.png b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation.png new file mode 100644 index 00000000000..f7213e8733e Binary files /dev/null and b/apps/docs/static/img/intelligent-context-condensation/intelligent-context-condensation.png differ diff --git a/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing-1.png b/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing-1.png new file mode 100644 index 00000000000..40a40aebcb0 Binary files /dev/null and b/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing-1.png differ diff --git a/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing-2.png b/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing-2.png new file mode 100644 index 00000000000..8fee828d8cf Binary files /dev/null and b/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing-2.png differ diff --git a/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing.png b/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing.png new file mode 100644 index 00000000000..294730821e7 Binary files /dev/null and b/apps/docs/static/img/intelligent-context-condensing/intelligent-context-condensing.png differ diff --git a/apps/docs/static/img/litellm/litellm.png b/apps/docs/static/img/litellm/litellm.png new file mode 100644 index 00000000000..3605db1f620 Binary files /dev/null and b/apps/docs/static/img/litellm/litellm.png differ diff --git a/apps/docs/static/img/marketplace/marketplace-1.png b/apps/docs/static/img/marketplace/marketplace-1.png new file mode 100644 index 00000000000..6c7e0095b84 Binary files /dev/null and b/apps/docs/static/img/marketplace/marketplace-1.png differ diff --git a/apps/docs/static/img/marketplace/marketplace-2.png b/apps/docs/static/img/marketplace/marketplace-2.png new file mode 100644 index 00000000000..715705534cd Binary files /dev/null and b/apps/docs/static/img/marketplace/marketplace-2.png differ diff --git a/apps/docs/static/img/marketplace/marketplace-3.png b/apps/docs/static/img/marketplace/marketplace-3.png new file mode 100644 index 00000000000..af222cc4c8b Binary files /dev/null and b/apps/docs/static/img/marketplace/marketplace-3.png differ diff --git a/apps/docs/static/img/marketplace/marketplace-4.png b/apps/docs/static/img/marketplace/marketplace-4.png new file mode 100644 index 00000000000..938f1fdd5e5 Binary files /dev/null and b/apps/docs/static/img/marketplace/marketplace-4.png differ diff --git a/apps/docs/static/img/marketplace/marketplace-5.png b/apps/docs/static/img/marketplace/marketplace-5.png new file mode 100644 index 00000000000..d1cde75943f Binary files /dev/null and b/apps/docs/static/img/marketplace/marketplace-5.png differ diff --git a/apps/docs/static/img/marketplace/marketplace-6.png b/apps/docs/static/img/marketplace/marketplace-6.png new file mode 100644 index 00000000000..e31b6e2926f Binary files /dev/null and b/apps/docs/static/img/marketplace/marketplace-6.png differ diff --git a/apps/docs/static/img/marketplace/marketplace-7.png b/apps/docs/static/img/marketplace/marketplace-7.png new file mode 100644 index 00000000000..00f1e0028f8 Binary files /dev/null and b/apps/docs/static/img/marketplace/marketplace-7.png differ diff --git a/apps/docs/static/img/marketplace/marketplace.png b/apps/docs/static/img/marketplace/marketplace.png new file mode 100644 index 00000000000..97dcb2c600d Binary files /dev/null and b/apps/docs/static/img/marketplace/marketplace.png differ diff --git a/apps/docs/static/img/message-queueing/message-queueing.png b/apps/docs/static/img/message-queueing/message-queueing.png new file mode 100644 index 00000000000..2e4087ff88b Binary files /dev/null and b/apps/docs/static/img/message-queueing/message-queueing.png differ diff --git a/apps/docs/static/img/model-temperature/model-temperature.gif b/apps/docs/static/img/model-temperature/model-temperature.gif new file mode 100644 index 00000000000..0e6b7a9b162 Binary files /dev/null and b/apps/docs/static/img/model-temperature/model-temperature.gif differ diff --git a/apps/docs/static/img/model-temperature/model-temperature.png b/apps/docs/static/img/model-temperature/model-temperature.png new file mode 100644 index 00000000000..89152261155 Binary files /dev/null and b/apps/docs/static/img/model-temperature/model-temperature.png differ diff --git a/apps/docs/static/img/modes/modes-1.png b/apps/docs/static/img/modes/modes-1.png new file mode 100644 index 00000000000..465c98b566d Binary files /dev/null and b/apps/docs/static/img/modes/modes-1.png differ diff --git a/apps/docs/static/img/modes/modes-2.png b/apps/docs/static/img/modes/modes-2.png new file mode 100644 index 00000000000..eb98c17432f Binary files /dev/null and b/apps/docs/static/img/modes/modes-2.png differ diff --git a/apps/docs/static/img/modes/modes.png b/apps/docs/static/img/modes/modes.png new file mode 100644 index 00000000000..cc0cd8358b5 Binary files /dev/null and b/apps/docs/static/img/modes/modes.png differ diff --git a/apps/docs/static/img/power-steering/power-steering.png b/apps/docs/static/img/power-steering/power-steering.png new file mode 100644 index 00000000000..f6ecaf514ed Binary files /dev/null and b/apps/docs/static/img/power-steering/power-steering.png differ diff --git a/apps/docs/static/img/recommended-mcp-servers/context7-global-setup-fixed.png b/apps/docs/static/img/recommended-mcp-servers/context7-global-setup-fixed.png new file mode 100644 index 00000000000..785d17ca356 Binary files /dev/null and b/apps/docs/static/img/recommended-mcp-servers/context7-global-setup-fixed.png differ diff --git a/apps/docs/static/img/recommended-mcp-servers/context7-project-setup-fixed.png b/apps/docs/static/img/recommended-mcp-servers/context7-project-setup-fixed.png new file mode 100644 index 00000000000..bb8b6023502 Binary files /dev/null and b/apps/docs/static/img/recommended-mcp-servers/context7-project-setup-fixed.png differ diff --git a/apps/docs/static/img/recommended-mcp-servers/context7-running-fixed.png b/apps/docs/static/img/recommended-mcp-servers/context7-running-fixed.png new file mode 100644 index 00000000000..1e80a0f082d Binary files /dev/null and b/apps/docs/static/img/recommended-mcp-servers/context7-running-fixed.png differ diff --git a/apps/docs/static/img/reporting-errors/reporting-errors-1.png b/apps/docs/static/img/reporting-errors/reporting-errors-1.png new file mode 100644 index 00000000000..472b7e17d32 Binary files /dev/null and b/apps/docs/static/img/reporting-errors/reporting-errors-1.png differ diff --git a/apps/docs/static/img/reporting-errors/reporting-errors-2.png b/apps/docs/static/img/reporting-errors/reporting-errors-2.png new file mode 100644 index 00000000000..9f3838efb09 Binary files /dev/null and b/apps/docs/static/img/reporting-errors/reporting-errors-2.png differ diff --git a/apps/docs/static/img/right-column-roo.gif b/apps/docs/static/img/right-column-roo.gif new file mode 100644 index 00000000000..eaa058efcb7 Binary files /dev/null and b/apps/docs/static/img/right-column-roo.gif differ diff --git a/apps/web-roo-code/public/Roo-Code-Logo-Horiz-blk.svg b/apps/docs/static/img/roo-code-logo-dark.svg similarity index 100% rename from apps/web-roo-code/public/Roo-Code-Logo-Horiz-blk.svg rename to apps/docs/static/img/roo-code-logo-dark.svg diff --git a/apps/web-roo-code/public/Roo-Code-Logo-Horiz-white.svg b/apps/docs/static/img/roo-code-logo-white.svg similarity index 100% rename from apps/web-roo-code/public/Roo-Code-Logo-Horiz-white.svg rename to apps/docs/static/img/roo-code-logo-white.svg diff --git a/apps/docs/static/img/settings-management/settings-management.png b/apps/docs/static/img/settings-management/settings-management.png new file mode 100644 index 00000000000..e19dfbb1e82 Binary files /dev/null and b/apps/docs/static/img/settings-management/settings-management.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-1.png b/apps/docs/static/img/shell-integration/shell-integration-1.png new file mode 100644 index 00000000000..d089be39ed7 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-1.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-10.png b/apps/docs/static/img/shell-integration/shell-integration-10.png new file mode 100644 index 00000000000..22347adccaf Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-10.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-11.png b/apps/docs/static/img/shell-integration/shell-integration-11.png new file mode 100644 index 00000000000..4aa836e2422 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-11.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-12.png b/apps/docs/static/img/shell-integration/shell-integration-12.png new file mode 100644 index 00000000000..a356a606a6f Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-12.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-13.png b/apps/docs/static/img/shell-integration/shell-integration-13.png new file mode 100644 index 00000000000..f4255e8dee8 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-13.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-14.png b/apps/docs/static/img/shell-integration/shell-integration-14.png new file mode 100644 index 00000000000..c2de52ec35b Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-14.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-15.png b/apps/docs/static/img/shell-integration/shell-integration-15.png new file mode 100644 index 00000000000..7ca4a0ade0d Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-15.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-16.png b/apps/docs/static/img/shell-integration/shell-integration-16.png new file mode 100644 index 00000000000..3a684723676 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-16.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-17.png b/apps/docs/static/img/shell-integration/shell-integration-17.png new file mode 100644 index 00000000000..56106648d83 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-17.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-18.png b/apps/docs/static/img/shell-integration/shell-integration-18.png new file mode 100644 index 00000000000..d13285acebd Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-18.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-19.png b/apps/docs/static/img/shell-integration/shell-integration-19.png new file mode 100644 index 00000000000..b69e726b685 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-19.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-2.png b/apps/docs/static/img/shell-integration/shell-integration-2.png new file mode 100644 index 00000000000..e55a511675b Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-2.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-20.png b/apps/docs/static/img/shell-integration/shell-integration-20.png new file mode 100644 index 00000000000..dc5344d3cc5 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-20.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-21.png b/apps/docs/static/img/shell-integration/shell-integration-21.png new file mode 100644 index 00000000000..3b1ef2f15d9 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-21.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-22.png b/apps/docs/static/img/shell-integration/shell-integration-22.png new file mode 100644 index 00000000000..0e87d269329 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-22.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-23.png b/apps/docs/static/img/shell-integration/shell-integration-23.png new file mode 100644 index 00000000000..7ae3f7d6906 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-23.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-24.png b/apps/docs/static/img/shell-integration/shell-integration-24.png new file mode 100644 index 00000000000..e0815d15bb7 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-24.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-3.png b/apps/docs/static/img/shell-integration/shell-integration-3.png new file mode 100644 index 00000000000..24c8532a44a Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-3.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-4.png b/apps/docs/static/img/shell-integration/shell-integration-4.png new file mode 100644 index 00000000000..6faa30e5b46 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-4.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-5.png b/apps/docs/static/img/shell-integration/shell-integration-5.png new file mode 100644 index 00000000000..f80ba0dcfbe Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-5.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-6.png b/apps/docs/static/img/shell-integration/shell-integration-6.png new file mode 100644 index 00000000000..c9e34f0b3c9 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-6.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-7.png b/apps/docs/static/img/shell-integration/shell-integration-7.png new file mode 100644 index 00000000000..3011004ec7c Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-7.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-8.png b/apps/docs/static/img/shell-integration/shell-integration-8.png new file mode 100644 index 00000000000..6c5b7937909 Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-8.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration-9.png b/apps/docs/static/img/shell-integration/shell-integration-9.png new file mode 100644 index 00000000000..0b3a6355cfb Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration-9.png differ diff --git a/apps/docs/static/img/shell-integration/shell-integration.png b/apps/docs/static/img/shell-integration/shell-integration.png new file mode 100644 index 00000000000..87749d1a09a Binary files /dev/null and b/apps/docs/static/img/shell-integration/shell-integration.png differ diff --git a/apps/docs/static/img/slash-commands/slash-commands-1.png b/apps/docs/static/img/slash-commands/slash-commands-1.png new file mode 100644 index 00000000000..dd8881cec93 Binary files /dev/null and b/apps/docs/static/img/slash-commands/slash-commands-1.png differ diff --git a/apps/docs/static/img/slash-commands/slash-commands-2.png b/apps/docs/static/img/slash-commands/slash-commands-2.png new file mode 100644 index 00000000000..50bc6178852 Binary files /dev/null and b/apps/docs/static/img/slash-commands/slash-commands-2.png differ diff --git a/apps/docs/static/img/slash-commands/slash-commands-3.png b/apps/docs/static/img/slash-commands/slash-commands-3.png new file mode 100644 index 00000000000..1949e87fa69 Binary files /dev/null and b/apps/docs/static/img/slash-commands/slash-commands-3.png differ diff --git a/apps/docs/static/img/slash-commands/slash-commands-4.png b/apps/docs/static/img/slash-commands/slash-commands-4.png new file mode 100644 index 00000000000..821e3c8d530 Binary files /dev/null and b/apps/docs/static/img/slash-commands/slash-commands-4.png differ diff --git a/apps/docs/static/img/slash-commands/slash-commands-5.png b/apps/docs/static/img/slash-commands/slash-commands-5.png new file mode 100644 index 00000000000..a97a016bc35 Binary files /dev/null and b/apps/docs/static/img/slash-commands/slash-commands-5.png differ diff --git a/apps/docs/static/img/slash-commands/slash-commands-6.png b/apps/docs/static/img/slash-commands/slash-commands-6.png new file mode 100644 index 00000000000..d70837e7d8b Binary files /dev/null and b/apps/docs/static/img/slash-commands/slash-commands-6.png differ diff --git a/apps/docs/static/img/slash-commands/slash-commands.png b/apps/docs/static/img/slash-commands/slash-commands.png new file mode 100644 index 00000000000..3f8570131c9 Binary files /dev/null and b/apps/docs/static/img/slash-commands/slash-commands.png differ diff --git a/apps/docs/static/img/social-share.png b/apps/docs/static/img/social-share.png new file mode 100644 index 00000000000..9b8709330cc Binary files /dev/null and b/apps/docs/static/img/social-share.png differ diff --git a/apps/docs/static/img/suggested-responses/suggested-responses-1.png b/apps/docs/static/img/suggested-responses/suggested-responses-1.png new file mode 100644 index 00000000000..76fe29f5366 Binary files /dev/null and b/apps/docs/static/img/suggested-responses/suggested-responses-1.png differ diff --git a/apps/docs/static/img/suggested-responses/suggested-responses.png b/apps/docs/static/img/suggested-responses/suggested-responses.png new file mode 100644 index 00000000000..e9e8def917c Binary files /dev/null and b/apps/docs/static/img/suggested-responses/suggested-responses.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-1.gif b/apps/docs/static/img/task-sharing/task-sharing-1.gif new file mode 100644 index 00000000000..762504b3917 Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-1.gif differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-1.png b/apps/docs/static/img/task-sharing/task-sharing-1.png new file mode 100644 index 00000000000..0caf7f50baf Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-1.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-10.png b/apps/docs/static/img/task-sharing/task-sharing-10.png new file mode 100644 index 00000000000..e86d761398d Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-10.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-11.png b/apps/docs/static/img/task-sharing/task-sharing-11.png new file mode 100644 index 00000000000..0a28e342b2a Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-11.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-12.png b/apps/docs/static/img/task-sharing/task-sharing-12.png new file mode 100644 index 00000000000..ca66a64c8be Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-12.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-2.png b/apps/docs/static/img/task-sharing/task-sharing-2.png new file mode 100644 index 00000000000..f3ade40bee6 Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-2.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-3.png b/apps/docs/static/img/task-sharing/task-sharing-3.png new file mode 100644 index 00000000000..3030a3ec30f Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-3.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-4.png b/apps/docs/static/img/task-sharing/task-sharing-4.png new file mode 100644 index 00000000000..8c339408051 Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-4.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-5.png b/apps/docs/static/img/task-sharing/task-sharing-5.png new file mode 100644 index 00000000000..5908e2c5f26 Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-5.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-6.png b/apps/docs/static/img/task-sharing/task-sharing-6.png new file mode 100644 index 00000000000..ea50f5dd5df Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-6.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-7.png b/apps/docs/static/img/task-sharing/task-sharing-7.png new file mode 100644 index 00000000000..3f31155bfd1 Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-7.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-8.png b/apps/docs/static/img/task-sharing/task-sharing-8.png new file mode 100644 index 00000000000..063f6505ff5 Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-8.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing-9.png b/apps/docs/static/img/task-sharing/task-sharing-9.png new file mode 100644 index 00000000000..d979fb0ae47 Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing-9.png differ diff --git a/apps/docs/static/img/task-sharing/task-sharing.png b/apps/docs/static/img/task-sharing/task-sharing.png new file mode 100644 index 00000000000..8df379a875c Binary files /dev/null and b/apps/docs/static/img/task-sharing/task-sharing.png differ diff --git a/apps/docs/static/img/task-todo-list/task-todo-list-1.png b/apps/docs/static/img/task-todo-list/task-todo-list-1.png new file mode 100644 index 00000000000..b37caa1cd92 Binary files /dev/null and b/apps/docs/static/img/task-todo-list/task-todo-list-1.png differ diff --git a/apps/docs/static/img/task-todo-list/task-todo-list-2.png b/apps/docs/static/img/task-todo-list/task-todo-list-2.png new file mode 100644 index 00000000000..42ae5397626 Binary files /dev/null and b/apps/docs/static/img/task-todo-list/task-todo-list-2.png differ diff --git a/apps/docs/static/img/task-todo-list/task-todo-list-3.png b/apps/docs/static/img/task-todo-list/task-todo-list-3.png new file mode 100644 index 00000000000..79072b309af Binary files /dev/null and b/apps/docs/static/img/task-todo-list/task-todo-list-3.png differ diff --git a/apps/docs/static/img/task-todo-list/task-todo-list-4.png b/apps/docs/static/img/task-todo-list/task-todo-list-4.png new file mode 100644 index 00000000000..6378b5db8af Binary files /dev/null and b/apps/docs/static/img/task-todo-list/task-todo-list-4.png differ diff --git a/apps/docs/static/img/task-todo-list/task-todo-list-5.png b/apps/docs/static/img/task-todo-list/task-todo-list-5.png new file mode 100644 index 00000000000..14033a4e3c1 Binary files /dev/null and b/apps/docs/static/img/task-todo-list/task-todo-list-5.png differ diff --git a/apps/docs/static/img/task-todo-list/task-todo-list-6.png b/apps/docs/static/img/task-todo-list/task-todo-list-6.png new file mode 100644 index 00000000000..3f96434656f Binary files /dev/null and b/apps/docs/static/img/task-todo-list/task-todo-list-6.png differ diff --git a/apps/docs/static/img/task-todo-list/task-todo-list.png b/apps/docs/static/img/task-todo-list/task-todo-list.png new file mode 100644 index 00000000000..6d3d57667fc Binary files /dev/null and b/apps/docs/static/img/task-todo-list/task-todo-list.png differ diff --git a/apps/docs/static/img/the-chat-interface/the-chat-interface-1.png b/apps/docs/static/img/the-chat-interface/the-chat-interface-1.png new file mode 100644 index 00000000000..328d802d3bd Binary files /dev/null and b/apps/docs/static/img/the-chat-interface/the-chat-interface-1.png differ diff --git a/apps/docs/static/img/typing-your-requests/naturally.gif b/apps/docs/static/img/typing-your-requests/naturally.gif new file mode 100644 index 00000000000..24449aa4479 Binary files /dev/null and b/apps/docs/static/img/typing-your-requests/naturally.gif differ diff --git a/apps/docs/static/img/typing-your-requests/typing-your-requests.png b/apps/docs/static/img/typing-your-requests/typing-your-requests.png new file mode 100644 index 00000000000..14b8bda0310 Binary files /dev/null and b/apps/docs/static/img/typing-your-requests/typing-your-requests.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-1.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-1.png new file mode 100644 index 00000000000..e602c103dea Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-1.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-10.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-10.png new file mode 100644 index 00000000000..6a63955801f Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-10.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-2.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-2.png new file mode 100644 index 00000000000..59f80a2a46f Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-2.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-3.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-3.png new file mode 100644 index 00000000000..064c185e7b0 Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-3.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-4.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-4.png new file mode 100644 index 00000000000..dff3532ecd9 Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-4.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-5.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-5.png new file mode 100644 index 00000000000..a995f04cd1e Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-5.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-6.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-6.png new file mode 100644 index 00000000000..5f643b267f0 Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-6.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-7.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-7.png new file mode 100644 index 00000000000..3b3a75a0624 Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-7.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-8.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-8.png new file mode 100644 index 00000000000..b25949e1786 Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-8.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-9.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-9.png new file mode 100644 index 00000000000..1e5ea319ecf Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo-9.png differ diff --git a/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo.png b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo.png new file mode 100644 index 00000000000..f217b786387 Binary files /dev/null and b/apps/docs/static/img/using-mcp-in-roo/using-mcp-in-roo.png differ diff --git a/apps/docs/static/img/using-modes/using-modes-1.png b/apps/docs/static/img/using-modes/using-modes-1.png new file mode 100644 index 00000000000..d25686fb93c Binary files /dev/null and b/apps/docs/static/img/using-modes/using-modes-1.png differ diff --git a/apps/docs/static/img/using-modes/using-modes-2.png b/apps/docs/static/img/using-modes/using-modes-2.png new file mode 100644 index 00000000000..e3cb7c43bf7 Binary files /dev/null and b/apps/docs/static/img/using-modes/using-modes-2.png differ diff --git a/apps/docs/static/img/using-modes/using-modes.png b/apps/docs/static/img/using-modes/using-modes.png new file mode 100644 index 00000000000..b22f93a913c Binary files /dev/null and b/apps/docs/static/img/using-modes/using-modes.png differ diff --git a/apps/docs/static/img/v3.11.13/v3.11.13-1.png b/apps/docs/static/img/v3.11.13/v3.11.13-1.png new file mode 100644 index 00000000000..27bb8e46387 Binary files /dev/null and b/apps/docs/static/img/v3.11.13/v3.11.13-1.png differ diff --git a/apps/docs/static/img/v3.11.13/v3.11.13.png b/apps/docs/static/img/v3.11.13/v3.11.13.png new file mode 100644 index 00000000000..631a95d1124 Binary files /dev/null and b/apps/docs/static/img/v3.11.13/v3.11.13.png differ diff --git a/apps/docs/static/img/v3.11/v3.11-1.png b/apps/docs/static/img/v3.11/v3.11-1.png new file mode 100644 index 00000000000..22a7c73f815 Binary files /dev/null and b/apps/docs/static/img/v3.11/v3.11-1.png differ diff --git a/apps/docs/static/img/v3.11/v3.11-2.png b/apps/docs/static/img/v3.11/v3.11-2.png new file mode 100644 index 00000000000..b51f1b9b040 Binary files /dev/null and b/apps/docs/static/img/v3.11/v3.11-2.png differ diff --git a/apps/docs/static/img/v3.11/v3.11.png b/apps/docs/static/img/v3.11/v3.11.png new file mode 100644 index 00000000000..6966f45af7b Binary files /dev/null and b/apps/docs/static/img/v3.11/v3.11.png differ diff --git a/apps/docs/static/img/v3.14.0/v3.14.0.png b/apps/docs/static/img/v3.14.0/v3.14.0.png new file mode 100644 index 00000000000..3204855bed8 Binary files /dev/null and b/apps/docs/static/img/v3.14.0/v3.14.0.png differ diff --git a/apps/docs/static/img/v3.14.2/v3.14.2.png b/apps/docs/static/img/v3.14.2/v3.14.2.png new file mode 100644 index 00000000000..754ae9568ab Binary files /dev/null and b/apps/docs/static/img/v3.14.2/v3.14.2.png differ diff --git a/apps/docs/static/img/v3.14.3/v3.14.3-1.png b/apps/docs/static/img/v3.14.3/v3.14.3-1.png new file mode 100644 index 00000000000..d62608d67f6 Binary files /dev/null and b/apps/docs/static/img/v3.14.3/v3.14.3-1.png differ diff --git a/apps/docs/static/img/v3.14.3/v3.14.3.png b/apps/docs/static/img/v3.14.3/v3.14.3.png new file mode 100644 index 00000000000..066049465b0 Binary files /dev/null and b/apps/docs/static/img/v3.14.3/v3.14.3.png differ diff --git a/apps/docs/static/img/v3.15.0/v3.15.0-1.png b/apps/docs/static/img/v3.15.0/v3.15.0-1.png new file mode 100644 index 00000000000..8063006af54 Binary files /dev/null and b/apps/docs/static/img/v3.15.0/v3.15.0-1.png differ diff --git a/apps/docs/static/img/v3.15.0/v3.15.0-2.png b/apps/docs/static/img/v3.15.0/v3.15.0-2.png new file mode 100644 index 00000000000..80b70cc163a Binary files /dev/null and b/apps/docs/static/img/v3.15.0/v3.15.0-2.png differ diff --git a/apps/docs/static/img/v3.15.0/v3.15.0.png b/apps/docs/static/img/v3.15.0/v3.15.0.png new file mode 100644 index 00000000000..c7fa55b1b97 Binary files /dev/null and b/apps/docs/static/img/v3.15.0/v3.15.0.png differ diff --git a/apps/docs/static/img/v3.15.2/v3.15.2.png b/apps/docs/static/img/v3.15.2/v3.15.2.png new file mode 100644 index 00000000000..1f1d12955cd Binary files /dev/null and b/apps/docs/static/img/v3.15.2/v3.15.2.png differ diff --git a/apps/docs/static/img/v3.15/v3.15-1.png b/apps/docs/static/img/v3.15/v3.15-1.png new file mode 100644 index 00000000000..37eb260d804 Binary files /dev/null and b/apps/docs/static/img/v3.15/v3.15-1.png differ diff --git a/apps/docs/static/img/v3.15/v3.15.png b/apps/docs/static/img/v3.15/v3.15.png new file mode 100644 index 00000000000..5a4ffd2fd42 Binary files /dev/null and b/apps/docs/static/img/v3.15/v3.15.png differ diff --git a/apps/docs/static/img/v3.16/v3.16-1.png b/apps/docs/static/img/v3.16/v3.16-1.png new file mode 100644 index 00000000000..ab3ac2f24b3 Binary files /dev/null and b/apps/docs/static/img/v3.16/v3.16-1.png differ diff --git a/apps/docs/static/img/v3.16/v3.16.gif b/apps/docs/static/img/v3.16/v3.16.gif new file mode 100644 index 00000000000..d9f4ba88f0a Binary files /dev/null and b/apps/docs/static/img/v3.16/v3.16.gif differ diff --git a/apps/docs/static/img/v3.16/v3.16.png b/apps/docs/static/img/v3.16/v3.16.png new file mode 100644 index 00000000000..ff433788caa Binary files /dev/null and b/apps/docs/static/img/v3.16/v3.16.png differ diff --git a/apps/docs/static/img/v3.17.0/v3.17.0.png b/apps/docs/static/img/v3.17.0/v3.17.0.png new file mode 100644 index 00000000000..f08f0e040f2 Binary files /dev/null and b/apps/docs/static/img/v3.17.0/v3.17.0.png differ diff --git a/apps/docs/static/img/v3.18.0/v3.18.0-1.png b/apps/docs/static/img/v3.18.0/v3.18.0-1.png new file mode 100644 index 00000000000..2ac54d5e913 Binary files /dev/null and b/apps/docs/static/img/v3.18.0/v3.18.0-1.png differ diff --git a/apps/docs/static/img/v3.18.0/v3.18.0.png b/apps/docs/static/img/v3.18.0/v3.18.0.png new file mode 100644 index 00000000000..b37da240c4d Binary files /dev/null and b/apps/docs/static/img/v3.18.0/v3.18.0.png differ diff --git a/apps/docs/static/img/v3.22.0/v3.22.0-1.png b/apps/docs/static/img/v3.22.0/v3.22.0-1.png new file mode 100644 index 00000000000..200fa276c1f Binary files /dev/null and b/apps/docs/static/img/v3.22.0/v3.22.0-1.png differ diff --git a/apps/docs/static/img/v3.22.0/v3.22.0.png b/apps/docs/static/img/v3.22.0/v3.22.0.png new file mode 100644 index 00000000000..1b0f5f1f1b5 Binary files /dev/null and b/apps/docs/static/img/v3.22.0/v3.22.0.png differ diff --git a/apps/docs/static/img/v3.23.0/v3.23.0-1.png b/apps/docs/static/img/v3.23.0/v3.23.0-1.png new file mode 100644 index 00000000000..aa75bd5e379 Binary files /dev/null and b/apps/docs/static/img/v3.23.0/v3.23.0-1.png differ diff --git a/apps/docs/static/img/v3.23.0/v3.23.0.png b/apps/docs/static/img/v3.23.0/v3.23.0.png new file mode 100644 index 00000000000..a97cd61c746 Binary files /dev/null and b/apps/docs/static/img/v3.23.0/v3.23.0.png differ diff --git a/apps/docs/static/img/v3.23/v3.23-1.png b/apps/docs/static/img/v3.23/v3.23-1.png new file mode 100644 index 00000000000..1b2fa6478ed Binary files /dev/null and b/apps/docs/static/img/v3.23/v3.23-1.png differ diff --git a/apps/docs/static/img/v3.23/v3.23.png b/apps/docs/static/img/v3.23/v3.23.png new file mode 100644 index 00000000000..1b2fa6478ed Binary files /dev/null and b/apps/docs/static/img/v3.23/v3.23.png differ diff --git a/apps/docs/static/img/v3.25.0/v3.25.0-1.png b/apps/docs/static/img/v3.25.0/v3.25.0-1.png new file mode 100644 index 00000000000..5159d219033 Binary files /dev/null and b/apps/docs/static/img/v3.25.0/v3.25.0-1.png differ diff --git a/apps/docs/static/img/v3.25.0/v3.25.0-2.png b/apps/docs/static/img/v3.25.0/v3.25.0-2.png new file mode 100644 index 00000000000..3243e4e34da Binary files /dev/null and b/apps/docs/static/img/v3.25.0/v3.25.0-2.png differ diff --git a/apps/docs/static/img/v3.25.0/v3.25.0.png b/apps/docs/static/img/v3.25.0/v3.25.0.png new file mode 100644 index 00000000000..64985a1bebb Binary files /dev/null and b/apps/docs/static/img/v3.25.0/v3.25.0.png differ diff --git a/apps/docs/static/img/v3.25.21/v3.25.21.png b/apps/docs/static/img/v3.25.21/v3.25.21.png new file mode 100644 index 00000000000..8f4b43e8810 Binary files /dev/null and b/apps/docs/static/img/v3.25.21/v3.25.21.png differ diff --git a/apps/docs/static/img/v3.25.5/v3.25.5-1.png b/apps/docs/static/img/v3.25.5/v3.25.5-1.png new file mode 100644 index 00000000000..11e27905174 Binary files /dev/null and b/apps/docs/static/img/v3.25.5/v3.25.5-1.png differ diff --git a/apps/docs/static/img/v3.25.5/v3.25.5.png b/apps/docs/static/img/v3.25.5/v3.25.5.png new file mode 100644 index 00000000000..b05bba1e601 Binary files /dev/null and b/apps/docs/static/img/v3.25.5/v3.25.5.png differ diff --git a/apps/docs/static/img/v3.26.0/v3.26.0.png b/apps/docs/static/img/v3.26.0/v3.26.0.png new file mode 100644 index 00000000000..393eaa94ebd Binary files /dev/null and b/apps/docs/static/img/v3.26.0/v3.26.0.png differ diff --git a/apps/docs/static/img/v3.26.1/v3.26.1.png b/apps/docs/static/img/v3.26.1/v3.26.1.png new file mode 100644 index 00000000000..9eb76ec7067 Binary files /dev/null and b/apps/docs/static/img/v3.26.1/v3.26.1.png differ diff --git a/apps/docs/static/img/v3.26.2/v3.26.2.png b/apps/docs/static/img/v3.26.2/v3.26.2.png new file mode 100644 index 00000000000..9b21da456e1 Binary files /dev/null and b/apps/docs/static/img/v3.26.2/v3.26.2.png differ diff --git a/apps/docs/static/img/v3.26.3/v3.26.3.png b/apps/docs/static/img/v3.26.3/v3.26.3.png new file mode 100644 index 00000000000..3d2b5cf729f Binary files /dev/null and b/apps/docs/static/img/v3.26.3/v3.26.3.png differ diff --git a/apps/docs/static/img/v3.26.4/v3.26.4.png b/apps/docs/static/img/v3.26.4/v3.26.4.png new file mode 100644 index 00000000000..76803b9f994 Binary files /dev/null and b/apps/docs/static/img/v3.26.4/v3.26.4.png differ diff --git a/apps/docs/static/img/v3.26.5/v3.26.5.png b/apps/docs/static/img/v3.26.5/v3.26.5.png new file mode 100644 index 00000000000..e986259ff50 Binary files /dev/null and b/apps/docs/static/img/v3.26.5/v3.26.5.png differ diff --git a/apps/docs/static/img/v3.26.6/v3.26.6.png b/apps/docs/static/img/v3.26.6/v3.26.6.png new file mode 100644 index 00000000000..96be69a4e36 Binary files /dev/null and b/apps/docs/static/img/v3.26.6/v3.26.6.png differ diff --git a/apps/docs/static/img/v3.26.7/v3.26.7.png b/apps/docs/static/img/v3.26.7/v3.26.7.png new file mode 100644 index 00000000000..44f2aa07357 Binary files /dev/null and b/apps/docs/static/img/v3.26.7/v3.26.7.png differ diff --git a/apps/docs/static/img/v3.27.0/v3.27.0.png b/apps/docs/static/img/v3.27.0/v3.27.0.png new file mode 100644 index 00000000000..fce290e357c Binary files /dev/null and b/apps/docs/static/img/v3.27.0/v3.27.0.png differ diff --git a/apps/docs/static/img/v3.28.0/v3.28.0.png b/apps/docs/static/img/v3.28.0/v3.28.0.png new file mode 100644 index 00000000000..14e22059618 Binary files /dev/null and b/apps/docs/static/img/v3.28.0/v3.28.0.png differ diff --git a/apps/docs/static/img/v3.28.1/v3.28.1.png b/apps/docs/static/img/v3.28.1/v3.28.1.png new file mode 100644 index 00000000000..d4c77ebcfef Binary files /dev/null and b/apps/docs/static/img/v3.28.1/v3.28.1.png differ diff --git a/apps/docs/static/img/v3.28.10/v3.28.10-1.png b/apps/docs/static/img/v3.28.10/v3.28.10-1.png new file mode 100644 index 00000000000..285d276b2e3 Binary files /dev/null and b/apps/docs/static/img/v3.28.10/v3.28.10-1.png differ diff --git a/apps/docs/static/img/v3.28.10/v3.28.10.png b/apps/docs/static/img/v3.28.10/v3.28.10.png new file mode 100644 index 00000000000..97f6cd429f4 Binary files /dev/null and b/apps/docs/static/img/v3.28.10/v3.28.10.png differ diff --git a/apps/docs/static/img/v3.28.14/v3.28.14.png b/apps/docs/static/img/v3.28.14/v3.28.14.png new file mode 100644 index 00000000000..4ef1acc7021 Binary files /dev/null and b/apps/docs/static/img/v3.28.14/v3.28.14.png differ diff --git a/apps/docs/static/img/v3.28.15/v3.28.15.png b/apps/docs/static/img/v3.28.15/v3.28.15.png new file mode 100644 index 00000000000..fc6e235befe Binary files /dev/null and b/apps/docs/static/img/v3.28.15/v3.28.15.png differ diff --git a/apps/docs/static/img/v3.28.16/v3.28.16.png b/apps/docs/static/img/v3.28.16/v3.28.16.png new file mode 100644 index 00000000000..5c4c31ece22 Binary files /dev/null and b/apps/docs/static/img/v3.28.16/v3.28.16.png differ diff --git a/apps/docs/static/img/v3.28.2/v3.28.2.png b/apps/docs/static/img/v3.28.2/v3.28.2.png new file mode 100644 index 00000000000..917c0a63098 Binary files /dev/null and b/apps/docs/static/img/v3.28.2/v3.28.2.png differ diff --git a/apps/docs/static/img/v3.28.3/v3.28.3.png b/apps/docs/static/img/v3.28.3/v3.28.3.png new file mode 100644 index 00000000000..883ef809700 Binary files /dev/null and b/apps/docs/static/img/v3.28.3/v3.28.3.png differ diff --git a/apps/docs/static/img/v3.28.4/v3.28.4.png b/apps/docs/static/img/v3.28.4/v3.28.4.png new file mode 100644 index 00000000000..ea1e82a8dda Binary files /dev/null and b/apps/docs/static/img/v3.28.4/v3.28.4.png differ diff --git a/apps/docs/static/img/v3.28.5/v3.28.5.png b/apps/docs/static/img/v3.28.5/v3.28.5.png new file mode 100644 index 00000000000..0a22c25c40d Binary files /dev/null and b/apps/docs/static/img/v3.28.5/v3.28.5.png differ diff --git a/apps/docs/static/img/v3.28.6/v3.28.6.png b/apps/docs/static/img/v3.28.6/v3.28.6.png new file mode 100644 index 00000000000..3ce1883756f Binary files /dev/null and b/apps/docs/static/img/v3.28.6/v3.28.6.png differ diff --git a/apps/docs/static/img/v3.28.7/v3.28.7.png b/apps/docs/static/img/v3.28.7/v3.28.7.png new file mode 100644 index 00000000000..d4690f19c99 Binary files /dev/null and b/apps/docs/static/img/v3.28.7/v3.28.7.png differ diff --git a/apps/docs/static/img/v3.28.8/v3.28.8.png b/apps/docs/static/img/v3.28.8/v3.28.8.png new file mode 100644 index 00000000000..8fcfa224538 Binary files /dev/null and b/apps/docs/static/img/v3.28.8/v3.28.8.png differ diff --git a/apps/docs/static/img/v3.28.9/v3.28.9.png b/apps/docs/static/img/v3.28.9/v3.28.9.png new file mode 100644 index 00000000000..a2272430035 Binary files /dev/null and b/apps/docs/static/img/v3.28.9/v3.28.9.png differ diff --git a/apps/docs/static/img/v3.29.0/v3.29.0.png b/apps/docs/static/img/v3.29.0/v3.29.0.png new file mode 100644 index 00000000000..8f9381fbae7 Binary files /dev/null and b/apps/docs/static/img/v3.29.0/v3.29.0.png differ diff --git a/apps/docs/static/img/v3.29.1/v3.29.1.png b/apps/docs/static/img/v3.29.1/v3.29.1.png new file mode 100644 index 00000000000..71dcd474b0e Binary files /dev/null and b/apps/docs/static/img/v3.29.1/v3.29.1.png differ diff --git a/apps/docs/static/img/v3.29.4/v3.29.4.png b/apps/docs/static/img/v3.29.4/v3.29.4.png new file mode 100644 index 00000000000..aa53e7bcdb1 Binary files /dev/null and b/apps/docs/static/img/v3.29.4/v3.29.4.png differ diff --git a/apps/docs/static/img/v3.30.0/v3.30.0.png b/apps/docs/static/img/v3.30.0/v3.30.0.png new file mode 100644 index 00000000000..8050aa49ce6 Binary files /dev/null and b/apps/docs/static/img/v3.30.0/v3.30.0.png differ diff --git a/apps/docs/static/img/v3.30.2/v3.30.2.png b/apps/docs/static/img/v3.30.2/v3.30.2.png new file mode 100644 index 00000000000..134bf2290db Binary files /dev/null and b/apps/docs/static/img/v3.30.2/v3.30.2.png differ diff --git a/apps/docs/static/img/v3.30.3/v3.30.3.png b/apps/docs/static/img/v3.30.3/v3.30.3.png new file mode 100644 index 00000000000..6fb3e84d780 Binary files /dev/null and b/apps/docs/static/img/v3.30.3/v3.30.3.png differ diff --git a/apps/docs/static/img/v3.31.0/v3.31.0.png b/apps/docs/static/img/v3.31.0/v3.31.0.png new file mode 100644 index 00000000000..03177536404 Binary files /dev/null and b/apps/docs/static/img/v3.31.0/v3.31.0.png differ diff --git a/apps/docs/static/img/v3.31.1/v3.31.1.png b/apps/docs/static/img/v3.31.1/v3.31.1.png new file mode 100644 index 00000000000..15499dd936f Binary files /dev/null and b/apps/docs/static/img/v3.31.1/v3.31.1.png differ diff --git a/apps/docs/static/img/v3.31.3/v3.31.3.png b/apps/docs/static/img/v3.31.3/v3.31.3.png new file mode 100644 index 00000000000..f3b88afa91c Binary files /dev/null and b/apps/docs/static/img/v3.31.3/v3.31.3.png differ diff --git a/apps/docs/static/img/v3.32.0/v3.32.0.png b/apps/docs/static/img/v3.32.0/v3.32.0.png new file mode 100644 index 00000000000..2c7744b9eb0 Binary files /dev/null and b/apps/docs/static/img/v3.32.0/v3.32.0.png differ diff --git a/apps/docs/static/img/v3.32.1/v3.32.1.png b/apps/docs/static/img/v3.32.1/v3.32.1.png new file mode 100644 index 00000000000..b5613c2bc79 Binary files /dev/null and b/apps/docs/static/img/v3.32.1/v3.32.1.png differ diff --git a/apps/docs/static/img/v3.33.0/v3.33.0.png b/apps/docs/static/img/v3.33.0/v3.33.0.png new file mode 100644 index 00000000000..12ba3224262 Binary files /dev/null and b/apps/docs/static/img/v3.33.0/v3.33.0.png differ diff --git a/apps/docs/static/img/v3.33.1/v3.33.1.png b/apps/docs/static/img/v3.33.1/v3.33.1.png new file mode 100644 index 00000000000..fc27da29dd5 Binary files /dev/null and b/apps/docs/static/img/v3.33.1/v3.33.1.png differ diff --git a/apps/docs/static/img/v3.33.3/v3.33.3.png b/apps/docs/static/img/v3.33.3/v3.33.3.png new file mode 100644 index 00000000000..c71aab016e3 Binary files /dev/null and b/apps/docs/static/img/v3.33.3/v3.33.3.png differ diff --git a/apps/docs/static/img/v3.34.0/v3.34.0.png b/apps/docs/static/img/v3.34.0/v3.34.0.png new file mode 100644 index 00000000000..bd3696c1c83 Binary files /dev/null and b/apps/docs/static/img/v3.34.0/v3.34.0.png differ diff --git a/apps/docs/static/img/v3.34.2/v3.34.2.png b/apps/docs/static/img/v3.34.2/v3.34.2.png new file mode 100644 index 00000000000..d32526a3369 Binary files /dev/null and b/apps/docs/static/img/v3.34.2/v3.34.2.png differ diff --git a/apps/docs/static/img/v3.34.3/v3.34.3.png b/apps/docs/static/img/v3.34.3/v3.34.3.png new file mode 100644 index 00000000000..a3785a3ec60 Binary files /dev/null and b/apps/docs/static/img/v3.34.3/v3.34.3.png differ diff --git a/apps/docs/static/img/v3.34.4/v3.34.4.png b/apps/docs/static/img/v3.34.4/v3.34.4.png new file mode 100644 index 00000000000..11103bf3e46 Binary files /dev/null and b/apps/docs/static/img/v3.34.4/v3.34.4.png differ diff --git a/apps/docs/static/img/v3.34.5/v3.34.5.png b/apps/docs/static/img/v3.34.5/v3.34.5.png new file mode 100644 index 00000000000..90ba9b06b49 Binary files /dev/null and b/apps/docs/static/img/v3.34.5/v3.34.5.png differ diff --git a/apps/docs/static/img/v3.34.6/v3.34.6.png b/apps/docs/static/img/v3.34.6/v3.34.6.png new file mode 100644 index 00000000000..cfba5aa1100 Binary files /dev/null and b/apps/docs/static/img/v3.34.6/v3.34.6.png differ diff --git a/apps/docs/static/img/v3.34.7/v3.34.7.png b/apps/docs/static/img/v3.34.7/v3.34.7.png new file mode 100644 index 00000000000..b9cad77d8ed Binary files /dev/null and b/apps/docs/static/img/v3.34.7/v3.34.7.png differ diff --git a/apps/docs/static/img/v3.34.8/v3.34.8.png b/apps/docs/static/img/v3.34.8/v3.34.8.png new file mode 100644 index 00000000000..aedbe48365a Binary files /dev/null and b/apps/docs/static/img/v3.34.8/v3.34.8.png differ diff --git a/apps/docs/static/img/v3.35.0/v3.35.0.png b/apps/docs/static/img/v3.35.0/v3.35.0.png new file mode 100644 index 00000000000..71857f1d114 Binary files /dev/null and b/apps/docs/static/img/v3.35.0/v3.35.0.png differ diff --git a/apps/docs/static/img/v3.35.2/v3.35.2.png b/apps/docs/static/img/v3.35.2/v3.35.2.png new file mode 100644 index 00000000000..085712cf970 Binary files /dev/null and b/apps/docs/static/img/v3.35.2/v3.35.2.png differ diff --git a/apps/docs/static/img/v3.36.0/v3.36.0.png b/apps/docs/static/img/v3.36.0/v3.36.0.png new file mode 100644 index 00000000000..79200d592af Binary files /dev/null and b/apps/docs/static/img/v3.36.0/v3.36.0.png differ diff --git a/apps/docs/static/img/v3.36.1/v3.36.1.png b/apps/docs/static/img/v3.36.1/v3.36.1.png new file mode 100644 index 00000000000..a3e8b11d0ec Binary files /dev/null and b/apps/docs/static/img/v3.36.1/v3.36.1.png differ diff --git a/apps/docs/static/img/v3.36.10/v3.36.10.png b/apps/docs/static/img/v3.36.10/v3.36.10.png new file mode 100644 index 00000000000..76fb30b98e0 Binary files /dev/null and b/apps/docs/static/img/v3.36.10/v3.36.10.png differ diff --git a/apps/docs/static/img/v3.36.11/v3.36.11.png b/apps/docs/static/img/v3.36.11/v3.36.11.png new file mode 100644 index 00000000000..d4b0843cf80 Binary files /dev/null and b/apps/docs/static/img/v3.36.11/v3.36.11.png differ diff --git a/apps/docs/static/img/v3.36.12/v3.36.12.png b/apps/docs/static/img/v3.36.12/v3.36.12.png new file mode 100644 index 00000000000..9b48925f1d1 Binary files /dev/null and b/apps/docs/static/img/v3.36.12/v3.36.12.png differ diff --git a/apps/docs/static/img/v3.36.13/v3.36.13.png b/apps/docs/static/img/v3.36.13/v3.36.13.png new file mode 100644 index 00000000000..ab7f00a7066 Binary files /dev/null and b/apps/docs/static/img/v3.36.13/v3.36.13.png differ diff --git a/apps/docs/static/img/v3.36.14/v3.36.14.png b/apps/docs/static/img/v3.36.14/v3.36.14.png new file mode 100644 index 00000000000..15009bb2f40 Binary files /dev/null and b/apps/docs/static/img/v3.36.14/v3.36.14.png differ diff --git a/apps/docs/static/img/v3.36.15/v3.36.15.png b/apps/docs/static/img/v3.36.15/v3.36.15.png new file mode 100644 index 00000000000..549f02bd5de Binary files /dev/null and b/apps/docs/static/img/v3.36.15/v3.36.15.png differ diff --git a/apps/docs/static/img/v3.36.2/v3.36.2.png b/apps/docs/static/img/v3.36.2/v3.36.2.png new file mode 100644 index 00000000000..bd30b350de0 Binary files /dev/null and b/apps/docs/static/img/v3.36.2/v3.36.2.png differ diff --git a/apps/docs/static/img/v3.36.3/v3.36.3.png b/apps/docs/static/img/v3.36.3/v3.36.3.png new file mode 100644 index 00000000000..279a2f04190 Binary files /dev/null and b/apps/docs/static/img/v3.36.3/v3.36.3.png differ diff --git a/apps/docs/static/img/v3.36.4/v3.36.4.png b/apps/docs/static/img/v3.36.4/v3.36.4.png new file mode 100644 index 00000000000..914765bfb8d Binary files /dev/null and b/apps/docs/static/img/v3.36.4/v3.36.4.png differ diff --git a/apps/docs/static/img/v3.36.5/v3.36.5.png b/apps/docs/static/img/v3.36.5/v3.36.5.png new file mode 100644 index 00000000000..058fc031052 Binary files /dev/null and b/apps/docs/static/img/v3.36.5/v3.36.5.png differ diff --git a/apps/docs/static/img/v3.36.6/v3.36.6.png b/apps/docs/static/img/v3.36.6/v3.36.6.png new file mode 100644 index 00000000000..0edb7428b92 Binary files /dev/null and b/apps/docs/static/img/v3.36.6/v3.36.6.png differ diff --git a/apps/docs/static/img/v3.36.9/v3.36.9.png b/apps/docs/static/img/v3.36.9/v3.36.9.png new file mode 100644 index 00000000000..b63ccb5f891 Binary files /dev/null and b/apps/docs/static/img/v3.36.9/v3.36.9.png differ diff --git a/apps/docs/static/img/v3.37.0/v3.37.0.png b/apps/docs/static/img/v3.37.0/v3.37.0.png new file mode 100644 index 00000000000..2bee909c5b9 Binary files /dev/null and b/apps/docs/static/img/v3.37.0/v3.37.0.png differ diff --git a/apps/docs/static/img/v3.37.1/v3.37.1.png b/apps/docs/static/img/v3.37.1/v3.37.1.png new file mode 100644 index 00000000000..586f4821db6 Binary files /dev/null and b/apps/docs/static/img/v3.37.1/v3.37.1.png differ diff --git a/apps/docs/static/img/v3.37/v3.37-1.png b/apps/docs/static/img/v3.37/v3.37-1.png new file mode 100644 index 00000000000..e00eba37d9b Binary files /dev/null and b/apps/docs/static/img/v3.37/v3.37-1.png differ diff --git a/apps/docs/static/img/v3.37/v3.37-2.png b/apps/docs/static/img/v3.37/v3.37-2.png new file mode 100644 index 00000000000..44ef150a6c0 Binary files /dev/null and b/apps/docs/static/img/v3.37/v3.37-2.png differ diff --git a/apps/docs/static/img/v3.37/v3.37.png b/apps/docs/static/img/v3.37/v3.37.png new file mode 100644 index 00000000000..469ff66ae62 Binary files /dev/null and b/apps/docs/static/img/v3.37/v3.37.png differ diff --git a/apps/docs/static/img/v3.38.0/v3.38.0.png b/apps/docs/static/img/v3.38.0/v3.38.0.png new file mode 100644 index 00000000000..46683a26df6 Binary files /dev/null and b/apps/docs/static/img/v3.38.0/v3.38.0.png differ diff --git a/apps/docs/static/img/v3.38.1/v3.38.1.png b/apps/docs/static/img/v3.38.1/v3.38.1.png new file mode 100644 index 00000000000..d77506be2cc Binary files /dev/null and b/apps/docs/static/img/v3.38.1/v3.38.1.png differ diff --git a/apps/docs/static/img/v3.38.2/v3.38.2.png b/apps/docs/static/img/v3.38.2/v3.38.2.png new file mode 100644 index 00000000000..d1e8f06d2f9 Binary files /dev/null and b/apps/docs/static/img/v3.38.2/v3.38.2.png differ diff --git a/apps/docs/static/img/v3.39.0/v3.39.0.png b/apps/docs/static/img/v3.39.0/v3.39.0.png new file mode 100644 index 00000000000..4f71720928d Binary files /dev/null and b/apps/docs/static/img/v3.39.0/v3.39.0.png differ diff --git a/apps/docs/static/img/v3.39.3/v3.39.3.png b/apps/docs/static/img/v3.39.3/v3.39.3.png new file mode 100644 index 00000000000..f8dcd92b698 Binary files /dev/null and b/apps/docs/static/img/v3.39.3/v3.39.3.png differ diff --git a/apps/docs/static/img/v3.40.0/v3.40.0.png b/apps/docs/static/img/v3.40.0/v3.40.0.png new file mode 100644 index 00000000000..32f2e717115 Binary files /dev/null and b/apps/docs/static/img/v3.40.0/v3.40.0.png differ diff --git a/apps/docs/static/img/v3.41.0/v3.41.0.png b/apps/docs/static/img/v3.41.0/v3.41.0.png new file mode 100644 index 00000000000..069858f2ddd Binary files /dev/null and b/apps/docs/static/img/v3.41.0/v3.41.0.png differ diff --git a/apps/docs/static/img/v3.41.1/v3.41.1.png b/apps/docs/static/img/v3.41.1/v3.41.1.png new file mode 100644 index 00000000000..c07c05aa6e2 Binary files /dev/null and b/apps/docs/static/img/v3.41.1/v3.41.1.png differ diff --git a/apps/docs/static/img/v3.42.0/v3.42.0.png b/apps/docs/static/img/v3.42.0/v3.42.0.png new file mode 100644 index 00000000000..80bb7ffa359 Binary files /dev/null and b/apps/docs/static/img/v3.42.0/v3.42.0.png differ diff --git a/apps/docs/static/img/v3.43.0/v3.43.0.png b/apps/docs/static/img/v3.43.0/v3.43.0.png new file mode 100644 index 00000000000..b38ad925cc3 Binary files /dev/null and b/apps/docs/static/img/v3.43.0/v3.43.0.png differ diff --git a/apps/docs/static/img/v3.44.0/v3.44.0.png b/apps/docs/static/img/v3.44.0/v3.44.0.png new file mode 100644 index 00000000000..ca92998b3c2 Binary files /dev/null and b/apps/docs/static/img/v3.44.0/v3.44.0.png differ diff --git a/apps/docs/static/img/v3.45.0/v3.45.0.png b/apps/docs/static/img/v3.45.0/v3.45.0.png new file mode 100644 index 00000000000..53e2016420f Binary files /dev/null and b/apps/docs/static/img/v3.45.0/v3.45.0.png differ diff --git a/apps/docs/static/img/v3.46.0/v3.46.0.png b/apps/docs/static/img/v3.46.0/v3.46.0.png new file mode 100644 index 00000000000..10aa0cf20c8 Binary files /dev/null and b/apps/docs/static/img/v3.46.0/v3.46.0.png differ diff --git a/apps/docs/static/img/v3.47.0/v3.47.0.png b/apps/docs/static/img/v3.47.0/v3.47.0.png new file mode 100644 index 00000000000..bc5460933ef Binary files /dev/null and b/apps/docs/static/img/v3.47.0/v3.47.0.png differ diff --git a/apps/docs/static/img/your-first-task/your-first-task-6.png b/apps/docs/static/img/your-first-task/your-first-task-6.png new file mode 100644 index 00000000000..93b88a043c9 Binary files /dev/null and b/apps/docs/static/img/your-first-task/your-first-task-6.png differ diff --git a/apps/docs/static/img/your-first-task/your-first-task-7.png b/apps/docs/static/img/your-first-task/your-first-task-7.png new file mode 100644 index 00000000000..01291cd8dbb Binary files /dev/null and b/apps/docs/static/img/your-first-task/your-first-task-7.png differ diff --git a/apps/docs/static/img/your-first-task/your-first-task-8.png b/apps/docs/static/img/your-first-task/your-first-task-8.png new file mode 100644 index 00000000000..e7fc32506d2 Binary files /dev/null and b/apps/docs/static/img/your-first-task/your-first-task-8.png differ diff --git a/apps/docs/static/img/your-first-task/your-first-task.png b/apps/docs/static/img/your-first-task/your-first-task.png new file mode 100644 index 00000000000..5a3cd874ddc Binary files /dev/null and b/apps/docs/static/img/your-first-task/your-first-task.png differ diff --git a/apps/docs/static/robots.txt b/apps/docs/static/robots.txt new file mode 100644 index 00000000000..725d0397439 --- /dev/null +++ b/apps/docs/static/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://roocodeinc.github.io/Roo-Code/sitemap.xml diff --git a/apps/docs/static/ui/arrow-left.svg b/apps/docs/static/ui/arrow-left.svg new file mode 100644 index 00000000000..d31609649c9 --- /dev/null +++ b/apps/docs/static/ui/arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/docs/static/ui/arrow-right.svg b/apps/docs/static/ui/arrow-right.svg new file mode 100644 index 00000000000..8405ae26f26 --- /dev/null +++ b/apps/docs/static/ui/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/docs/static/ui/check.svg b/apps/docs/static/ui/check.svg new file mode 100644 index 00000000000..6e390aaca89 --- /dev/null +++ b/apps/docs/static/ui/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/docs/static/ui/x.svg b/apps/docs/static/ui/x.svg new file mode 100644 index 00000000000..eb194fd2e6c --- /dev/null +++ b/apps/docs/static/ui/x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 00000000000..712d200a37b --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,8 @@ +{ + // This file is not used in compilation. It is here just for a nice editor experience. + "extends": "@docusaurus/tsconfig", + "compilerOptions": { + "baseUrl": "." + }, + "exclude": [".docusaurus", "build"] +} diff --git a/apps/docs/turbo.json b/apps/docs/turbo.json new file mode 100644 index 00000000000..1aa4810cc96 --- /dev/null +++ b/apps/docs/turbo.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["build/**"], + "inputs": [ + "docs/**", + "src/**", + "static/**", + "docusaurus.config.ts", + "sidebars.ts", + "package.json", + "tsconfig.json" + ] + } + } +} diff --git a/apps/vscode-e2e/package.json b/apps/vscode-e2e/package.json index d366f72a2d3..900ac6d753c 100644 --- a/apps/vscode-e2e/package.json +++ b/apps/vscode-e2e/package.json @@ -20,7 +20,6 @@ "@vscode/test-electron": "^2.4.0", "glob": "^11.1.0", "mocha": "^11.1.0", - "rimraf": "^6.0.1", - "typescript": "5.8.3" + "rimraf": "^6.0.1" } } diff --git a/apps/vscode-e2e/src/suite/modes.test.ts b/apps/vscode-e2e/src/suite/modes.test.ts index 7982f3cf22b..3c9d9a2418e 100644 --- a/apps/vscode-e2e/src/suite/modes.test.ts +++ b/apps/vscode-e2e/src/suite/modes.test.ts @@ -15,15 +15,13 @@ suite("Roo Code Modes", function () { const switchModesTaskId = await globalThis.api.startNewTask({ configuration: { mode: "code", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, - text: "For each of `architect`, `ask`, and `debug` use the `switch_mode` tool to switch to that mode.", + text: "Use the `switch_mode` tool to switch to ask mode.", }) await waitUntilCompleted({ api: globalThis.api, taskId: switchModesTaskId }) await globalThis.api.cancelCurrentTask() - assert.ok(modes.includes("architect")) assert.ok(modes.includes("ask")) - assert.ok(modes.includes("debug")) - assert.ok(modes.length === 3) + assert.ok(modes.length === 1) }) }) diff --git a/apps/vscode-e2e/src/suite/tools/read-file.test.ts b/apps/vscode-e2e/src/suite/tools/read-file.test.ts index 00aca7f58ab..6f3e28f60fc 100644 --- a/apps/vscode-e2e/src/suite/tools/read-file.test.ts +++ b/apps/vscode-e2e/src/suite/tools/read-file.test.ts @@ -376,7 +376,7 @@ suite.skip("Roo Code read_file Tool", function () { } }) - test("Should read file with line range", async function () { + test("Should read file with slice offset/limit", async function () { const api = globalThis.api const messages: ClineMessage[] = [] let taskCompleted = false @@ -446,7 +446,7 @@ suite.skip("Roo Code read_file Tool", function () { alwaysAllowReadOnly: true, alwaysAllowReadOnlyOutsideWorkspace: true, }, - text: `Use the read_file tool to read the file "${fileName}" and show me what's on lines 2, 3, and 4. The file contains lines like "Line 1", "Line 2", etc. Assume the file exists and you can read it directly.`, + text: `Use the read_file tool to read the file "${fileName}" using slice mode with offset=2 and limit=3 (1-based offset). The file contains lines like "Line 1", "Line 2", etc. After reading, show me the three lines you read.`, }) // Wait for task completion @@ -455,9 +455,8 @@ suite.skip("Roo Code read_file Tool", function () { // Verify tool was executed assert.ok(toolExecuted, "The read_file tool should have been executed") - // Verify the tool returned the correct lines (when line range is used) + // Verify the tool returned the correct lines (offset=2, limit=3 -> lines 2-4) if (toolResult && (toolResult as string).includes(" | ")) { - // The result includes line numbers assert.ok( (toolResult as string).includes("2 | Line 2"), "Tool result should include line 2 with line number", diff --git a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts index 380a77d179e..2c86ece3fbe 100644 --- a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts +++ b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts @@ -43,10 +43,10 @@ suite.skip("Roo Code use_mcp_tool Tool", function () { const mcpConfig = { mcpServers: { - filesystem: { - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem", workspaceDir], - alwaysAllow: [], + time: { + command: "uvx", + args: ["mcp-server-time"], + alwaysAllow: ["get_current_time", "convert_time"], }, }, } diff --git a/apps/web-evals/.env b/apps/web-evals/.env deleted file mode 100644 index 1bb6dd6dacb..00000000000 --- a/apps/web-evals/.env +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL=postgres://postgres:password@localhost:5433/evals_development diff --git a/apps/web-evals/.gitignore b/apps/web-evals/.gitignore deleted file mode 100644 index 443f3159ed3..00000000000 --- a/apps/web-evals/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# .env -!.env - -# next.js -.next - -# typescript -tsconfig.tsbuildinfo diff --git a/apps/web-evals/.next/cache/eslint/.cache_hvugjy b/apps/web-evals/.next/cache/eslint/.cache_hvugjy new file mode 100644 index 00000000000..45a545ce3fb --- /dev/null +++ b/apps/web-evals/.next/cache/eslint/.cache_hvugjy @@ -0,0 +1 @@ +[{"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/__tests__/killRun.spec.ts":"1","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/exercises.ts":"2","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/heartbeat.ts":"3","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/runners.ts":"4","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/runs.ts":"5","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/tasks.ts":"6","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts":"7","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts":"8","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/api/runs/[id]/stream/route.ts":"9","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/layout.tsx":"10","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/page.tsx":"11","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/[id]/page.tsx":"12","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/[id]/run-status.tsx":"13","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/[id]/run.tsx":"14","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/[id]/task-status.tsx":"15","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/new/new-run.tsx":"16","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/new/page.tsx":"17","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/new/settings-diff.tsx":"18","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/home/run.tsx":"19","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/home/runs.tsx":"20","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/layout/header.tsx":"21","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/layout/logo.tsx":"22","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/providers/index.ts":"23","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/providers/react-query-provider.tsx":"24","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/providers/theme-provider.tsx":"25","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/alert-dialog.tsx":"26","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/badge.tsx":"27","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/button.tsx":"28","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/checkbox.tsx":"29","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/command.tsx":"30","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/dialog.tsx":"31","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/drawer.tsx":"32","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/dropdown-menu.tsx":"33","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/form.tsx":"34","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/index.ts":"35","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/input.tsx":"36","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/label.tsx":"37","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/multi-select.tsx":"38","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/popover.tsx":"39","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/scroll-area.tsx":"40","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/select.tsx":"41","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/separator.tsx":"42","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/slider.tsx":"43","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/sonner.tsx":"44","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/table.tsx":"45","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/tabs.tsx":"46","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/textarea.tsx":"47","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/tooltip.tsx":"48","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-copy-run.ts":"49","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-event-source.ts":"50","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-fuzzy-model-search.ts":"51","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-open-router-models.ts":"52","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-roo-code-cloud-models.ts":"53","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-run-status.ts":"54","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/__tests__/formatters.spec.ts":"55","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/actions.ts":"56","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/formatters.ts":"57","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/schemas.ts":"58","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/server/__tests__/sse-stream.spec.ts":"59","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/server/redis.ts":"60","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/server/sse-stream.ts":"61","/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/utils.ts":"62"},{"size":6207,"mtime":1767022439531,"results":"63","hashOfConfig":"64"},{"size":675,"mtime":1767022063023,"results":"65","hashOfConfig":"64"},{"size":196,"mtime":1767022063023,"results":"66","hashOfConfig":"64"},{"size":197,"mtime":1767022063023,"results":"67","hashOfConfig":"64"},{"size":10315,"mtime":1767022439531,"results":"68","hashOfConfig":"64"},{"size":252,"mtime":1767022063023,"results":"69","hashOfConfig":"64"},{"size":2558,"mtime":1767022439531,"results":"70","hashOfConfig":"64"},{"size":4786,"mtime":1767022439531,"results":"71","hashOfConfig":"64"},{"size":1825,"mtime":1767022063026,"results":"72","hashOfConfig":"64"},{"size":951,"mtime":1767190693453,"results":"73","hashOfConfig":"64"},{"size":230,"mtime":1767022063026,"results":"74","hashOfConfig":"64"},{"size":310,"mtime":1767022439531,"results":"75","hashOfConfig":"64"},{"size":2606,"mtime":1767022439531,"results":"76","hashOfConfig":"64"},{"size":37551,"mtime":1767022439531,"results":"77","hashOfConfig":"64"},{"size":544,"mtime":1767022063026,"results":"78","hashOfConfig":"64"},{"size":32211,"mtime":1767190693453,"results":"79","hashOfConfig":"64"},{"size":156,"mtime":1767022063026,"results":"80","hashOfConfig":"64"},{"size":2016,"mtime":1767022439531,"results":"81","hashOfConfig":"64"},{"size":12987,"mtime":1767022439531,"results":"82","hashOfConfig":"64"},{"size":31122,"mtime":1767022439535,"results":"83","hashOfConfig":"64"},{"size":169,"mtime":1767022063026,"results":"84","hashOfConfig":"64"},{"size":2165,"mtime":1767022063026,"results":"85","hashOfConfig":"64"},{"size":109,"mtime":1767022063026,"results":"86","hashOfConfig":"64"},{"size":294,"mtime":1767022063026,"results":"87","hashOfConfig":"64"},{"size":396,"mtime":1767022063026,"results":"88","hashOfConfig":"64"},{"size":3591,"mtime":1767022063029,"results":"89","hashOfConfig":"64"},{"size":1521,"mtime":1767022063029,"results":"90","hashOfConfig":"64"},{"size":2028,"mtime":1767022063029,"results":"91","hashOfConfig":"64"},{"size":1160,"mtime":1767022439535,"results":"92","hashOfConfig":"64"},{"size":4343,"mtime":1767022063029,"results":"93","hashOfConfig":"64"},{"size":3595,"mtime":1767022063029,"results":"94","hashOfConfig":"64"},{"size":3838,"mtime":1767022063029,"results":"95","hashOfConfig":"64"},{"size":6120,"mtime":1767022063029,"results":"96","hashOfConfig":"64"},{"size":3500,"mtime":1767022063029,"results":"97","hashOfConfig":"64"},{"size":576,"mtime":1767022439535,"results":"98","hashOfConfig":"64"},{"size":904,"mtime":1767022063029,"results":"99","hashOfConfig":"64"},{"size":574,"mtime":1767022063029,"results":"100","hashOfConfig":"64"},{"size":9108,"mtime":1767022439535,"results":"101","hashOfConfig":"64"},{"size":1577,"mtime":1767022063029,"results":"102","hashOfConfig":"64"},{"size":1653,"mtime":1767022063029,"results":"103","hashOfConfig":"64"},{"size":5917,"mtime":1767022063029,"results":"104","hashOfConfig":"64"},{"size":669,"mtime":1767022063029,"results":"105","hashOfConfig":"64"},{"size":1786,"mtime":1767022063029,"results":"106","hashOfConfig":"64"},{"size":519,"mtime":1767022063029,"results":"107","hashOfConfig":"64"},{"size":2151,"mtime":1767022063029,"results":"108","hashOfConfig":"64"},{"size":3347,"mtime":1767022063029,"results":"109","hashOfConfig":"64"},{"size":707,"mtime":1767022063029,"results":"110","hashOfConfig":"64"},{"size":1780,"mtime":1767022063029,"results":"111","hashOfConfig":"64"},{"size":745,"mtime":1767022063029,"results":"112","hashOfConfig":"64"},{"size":2367,"mtime":1767022063029,"results":"113","hashOfConfig":"64"},{"size":869,"mtime":1767022439535,"results":"114","hashOfConfig":"64"},{"size":1011,"mtime":1767022439535,"results":"115","hashOfConfig":"64"},{"size":1753,"mtime":1767022439535,"results":"116","hashOfConfig":"64"},{"size":3119,"mtime":1767022439535,"results":"117","hashOfConfig":"64"},{"size":992,"mtime":1767022439535,"results":"118","hashOfConfig":"64"},{"size":502,"mtime":1767022063029,"results":"119","hashOfConfig":"64"},{"size":1684,"mtime":1767022439535,"results":"120","hashOfConfig":"64"},{"size":1159,"mtime":1767022439535,"results":"121","hashOfConfig":"64"},{"size":3021,"mtime":1767022063033,"results":"122","hashOfConfig":"64"},{"size":351,"mtime":1767022063033,"results":"123","hashOfConfig":"64"},{"size":1370,"mtime":1767022063033,"results":"124","hashOfConfig":"64"},{"size":165,"mtime":1767022063033,"results":"125","hashOfConfig":"64"},{"filePath":"126","messages":"127","suppressedMessages":"128","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1k3epwp",{"filePath":"129","messages":"130","suppressedMessages":"131","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"132","messages":"133","suppressedMessages":"134","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"135","messages":"136","suppressedMessages":"137","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"138","messages":"139","suppressedMessages":"140","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"141","messages":"142","suppressedMessages":"143","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"144","messages":"145","suppressedMessages":"146","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"147","messages":"148","suppressedMessages":"149","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"150","messages":"151","suppressedMessages":"152","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"153","messages":"154","suppressedMessages":"155","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"156","messages":"157","suppressedMessages":"158","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"159","messages":"160","suppressedMessages":"161","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"162","messages":"163","suppressedMessages":"164","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"165","messages":"166","suppressedMessages":"167","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"168","messages":"169","suppressedMessages":"170","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"171","messages":"172","suppressedMessages":"173","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"174","messages":"175","suppressedMessages":"176","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"177","messages":"178","suppressedMessages":"179","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"180","messages":"181","suppressedMessages":"182","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"183","messages":"184","suppressedMessages":"185","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"186","messages":"187","suppressedMessages":"188","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"189","messages":"190","suppressedMessages":"191","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"192","messages":"193","suppressedMessages":"194","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"195","messages":"196","suppressedMessages":"197","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"198","messages":"199","suppressedMessages":"200","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"201","messages":"202","suppressedMessages":"203","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"204","messages":"205","suppressedMessages":"206","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"207","messages":"208","suppressedMessages":"209","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"210","messages":"211","suppressedMessages":"212","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"213","messages":"214","suppressedMessages":"215","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"216","messages":"217","suppressedMessages":"218","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"219","messages":"220","suppressedMessages":"221","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"222","messages":"223","suppressedMessages":"224","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"225","messages":"226","suppressedMessages":"227","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"228","messages":"229","suppressedMessages":"230","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"231","messages":"232","suppressedMessages":"233","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"234","messages":"235","suppressedMessages":"236","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"237","messages":"238","suppressedMessages":"239","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"240","messages":"241","suppressedMessages":"242","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"243","messages":"244","suppressedMessages":"245","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"246","messages":"247","suppressedMessages":"248","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"249","messages":"250","suppressedMessages":"251","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"252","messages":"253","suppressedMessages":"254","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"255","messages":"256","suppressedMessages":"257","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"258","messages":"259","suppressedMessages":"260","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"261","messages":"262","suppressedMessages":"263","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"264","messages":"265","suppressedMessages":"266","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"267","messages":"268","suppressedMessages":"269","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"270","messages":"271","suppressedMessages":"272","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"273","messages":"274","suppressedMessages":"275","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"276","messages":"277","suppressedMessages":"278","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"279","messages":"280","suppressedMessages":"281","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"282","messages":"283","suppressedMessages":"284","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"285","messages":"286","suppressedMessages":"287","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"288","messages":"289","suppressedMessages":"290","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"291","messages":"292","suppressedMessages":"293","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"294","messages":"295","suppressedMessages":"296","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"297","messages":"298","suppressedMessages":"299","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"300","messages":"301","suppressedMessages":"302","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"303","messages":"304","suppressedMessages":"305","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"306","messages":"307","suppressedMessages":"308","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"309","messages":"310","suppressedMessages":"311","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/__tests__/killRun.spec.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/exercises.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/heartbeat.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/runners.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/runs.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/actions/tasks.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/api/runs/[id]/stream/route.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/layout.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/page.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/[id]/page.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/[id]/run-status.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/[id]/run.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/[id]/task-status.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/new/new-run.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/new/page.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/app/runs/new/settings-diff.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/home/run.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/home/runs.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/layout/header.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/layout/logo.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/providers/index.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/providers/react-query-provider.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/providers/theme-provider.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/alert-dialog.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/badge.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/button.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/checkbox.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/command.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/dialog.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/drawer.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/dropdown-menu.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/form.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/index.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/input.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/label.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/multi-select.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/popover.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/scroll-area.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/select.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/separator.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/slider.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/sonner.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/table.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/tabs.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/textarea.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/components/ui/tooltip.tsx",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-copy-run.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-event-source.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-fuzzy-model-search.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-open-router-models.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-roo-code-cloud-models.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/hooks/use-run-status.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/__tests__/formatters.spec.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/actions.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/formatters.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/schemas.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/server/__tests__/sse-stream.spec.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/server/redis.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/server/sse-stream.ts",[],[],"/home/jesus/Projects/datacoves/Roo-Code/apps/web-evals/src/lib/utils.ts",[],[]] \ No newline at end of file diff --git a/apps/web-evals/components.json b/apps/web-evals/components.json deleted file mode 100644 index 5bcedb31416..00000000000 --- a/apps/web-evals/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} diff --git a/apps/web-evals/eslint.config.mjs b/apps/web-evals/eslint.config.mjs deleted file mode 100644 index 024d6157d43..00000000000 --- a/apps/web-evals/eslint.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import { nextJsConfig } from "@roo-code/config-eslint/next-js" - -/** @type {import("eslint").Linter.Config} */ -export default [ - ...nextJsConfig, - { - rules: { - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - caughtErrorsIgnorePattern: "^_", - }, - ], - }, - }, -] diff --git a/apps/web-evals/next.config.ts b/apps/web-evals/next.config.ts deleted file mode 100644 index 08ed853fc38..00000000000 --- a/apps/web-evals/next.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { NextConfig } from "next" - -const nextConfig: NextConfig = { - webpack: (config) => { - config.resolve.extensionAlias = { ".js": [".ts", ".tsx", ".js", ".jsx"] } - return config - }, -} - -export default nextConfig diff --git a/apps/web-evals/package.json b/apps/web-evals/package.json deleted file mode 100644 index 9ba2c98c2c9..00000000000 --- a/apps/web-evals/package.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "@roo-code/web-evals", - "version": "0.0.0", - "type": "module", - "scripts": { - "lint": "next lint --max-warnings 0", - "check-types": "tsc -b", - "dev": "scripts/check-services.sh && next dev -p 3446", - "format": "prettier --write src", - "build": "next build", - "start": "next start -p 3446", - "clean": "rimraf tsconfig.tsbuildinfo .next .turbo" - }, - "dependencies": { - "@hookform/resolvers": "^5.1.1", - "@radix-ui/react-alert-dialog": "^1.1.7", - "@radix-ui/react-checkbox": "^1.1.5", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-dropdown-menu": "^2.1.7", - "@radix-ui/react-label": "^2.1.2", - "@radix-ui/react-popover": "^1.1.6", - "@radix-ui/react-scroll-area": "^1.2.3", - "@radix-ui/react-select": "^2.1.6", - "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slider": "^1.2.4", - "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-tabs": "^1.1.3", - "@radix-ui/react-tooltip": "^1.2.8", - "@roo-code/evals": "workspace:^", - "@roo-code/types": "workspace:^", - "@tanstack/react-query": "^5.69.0", - "archiver": "^7.0.1", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.0", - "fuzzysort": "^3.1.0", - "lucide-react": "^0.518.0", - "next": "~15.2.8", - "next-themes": "^0.4.6", - "p-map": "^7.0.3", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-hook-form": "^7.57.0", - "react-use": "^17.6.0", - "redis": "^5.5.5", - "sonner": "^2.0.5", - "tailwind-merge": "^3.3.0", - "tailwindcss-animate": "^1.0.7", - "vaul": "^1.1.2", - "zod": "^3.25.61" - }, - "devDependencies": { - "@roo-code/config-eslint": "workspace:^", - "@roo-code/config-typescript": "workspace:^", - "@tailwindcss/postcss": "^4", - "@types/archiver": "^7.0.0", - "@types/ps-tree": "^1.1.6", - "@types/react": "^18.3.23", - "@types/react-dom": "^18.3.5", - "tailwindcss": "^4", - "vitest": "^3.2.3" - } -} diff --git a/apps/web-evals/postcss.config.mjs b/apps/web-evals/postcss.config.mjs deleted file mode 100644 index 78452aadce7..00000000000 --- a/apps/web-evals/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -const config = { - plugins: ["@tailwindcss/postcss"], -} - -export default config diff --git a/apps/web-evals/public/.gitkeep b/apps/web-evals/public/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/apps/web-evals/scripts/check-services.sh b/apps/web-evals/scripts/check-services.sh deleted file mode 100755 index d72ffd54e8d..00000000000 --- a/apps/web-evals/scripts/check-services.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -if ! docker info &> /dev/null; then - echo "❌ Docker is not running. Please start Docker Desktop and try again." - exit 1 -fi - -if ! nc -z postgres 5433 2>/dev/null; then - echo "❌ PostgreSQL is not running on port 5432" - echo "💡 Start it with: pnpm --filter @roo-code/evals db:up" - exit 1 -fi - -if ! nc -z redis 6380 2>/dev/null; then - echo "❌ Redis is not running on port 6379" - echo "💡 Start it with: pnpm --filter @roo-code/evals redis:up" - exit 1 -fi - -echo "✅ All required services are running" diff --git a/apps/web-evals/src/actions/__tests__/killRun.spec.ts b/apps/web-evals/src/actions/__tests__/killRun.spec.ts deleted file mode 100644 index 814d70d9fca..00000000000 --- a/apps/web-evals/src/actions/__tests__/killRun.spec.ts +++ /dev/null @@ -1,207 +0,0 @@ -// npx vitest run src/actions/__tests__/killRun.spec.ts - -import { execFileSync } from "child_process" - -// Mock child_process -vi.mock("child_process", () => ({ - execFileSync: vi.fn(), - spawn: vi.fn(), -})) - -// Mock next/cache -vi.mock("next/cache", () => ({ - revalidatePath: vi.fn(), -})) - -// Mock redis client -vi.mock("@/lib/server/redis", () => ({ - redisClient: vi.fn().mockResolvedValue({ - del: vi.fn().mockResolvedValue(1), - }), -})) - -// Mock @roo-code/evals -vi.mock("@roo-code/evals", () => ({ - createRun: vi.fn(), - deleteRun: vi.fn(), - createTask: vi.fn(), - exerciseLanguages: [], - getExercisesForLanguage: vi.fn().mockResolvedValue([]), -})) - -// Mock timers to speed up tests -vi.useFakeTimers() - -// Import after mocks -import { killRun } from "../runs" - -const mockExecFileSync = execFileSync as ReturnType - -describe("killRun", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - vi.clearAllTimers() - }) - - it("should kill controller first, wait, then kill task containers", async () => { - const runId = 123 - - // execFileSync is used for all docker commands - mockExecFileSync - .mockReturnValueOnce("") // docker kill controller - .mockReturnValueOnce("evals-task-123-456.0\nevals-task-123-789.1\n") // docker ps - .mockReturnValueOnce("") // docker kill evals-task-123-456.0 - .mockReturnValueOnce("") // docker kill evals-task-123-789.1 - - const resultPromise = killRun(runId) - - // Fast-forward past the 10 second sleep - await vi.advanceTimersByTimeAsync(10000) - - const result = await resultPromise - - expect(result.success).toBe(true) - expect(result.killedContainers).toContain("evals-controller-123") - expect(result.killedContainers).toContain("evals-task-123-456.0") - expect(result.killedContainers).toContain("evals-task-123-789.1") - expect(result.errors).toHaveLength(0) - - // Verify execFileSync was called for docker kill - expect(mockExecFileSync).toHaveBeenNthCalledWith( - 1, - "docker", - ["kill", "evals-controller-123"], - expect.any(Object), - ) - // Verify execFileSync was called for docker ps with run-specific filter - expect(mockExecFileSync).toHaveBeenNthCalledWith( - 2, - "docker", - ["ps", "--format", "{{.Names}}", "--filter", "name=evals-task-123-"], - expect.any(Object), - ) - }) - - it("should continue killing runners even if controller is not running", async () => { - const runId = 456 - - mockExecFileSync - .mockImplementationOnce(() => { - throw new Error("No such container") - }) // controller kill fails - .mockReturnValueOnce("evals-task-456-100.0\n") // docker ps - .mockReturnValueOnce("") // docker kill task - - const resultPromise = killRun(runId) - await vi.advanceTimersByTimeAsync(10000) - const result = await resultPromise - - expect(result.success).toBe(true) - expect(result.killedContainers).toContain("evals-task-456-100.0") - // Controller not in list since it failed - expect(result.killedContainers).not.toContain("evals-controller-456") - }) - - it("should clear Redis state after killing containers", async () => { - const runId = 789 - - const mockDel = vi.fn().mockResolvedValue(1) - const { redisClient } = await import("@/lib/server/redis") - vi.mocked(redisClient).mockResolvedValue({ del: mockDel } as never) - - mockExecFileSync - .mockReturnValueOnce("") // controller kill - .mockReturnValueOnce("") // docker ps (no tasks) - - const resultPromise = killRun(runId) - await vi.advanceTimersByTimeAsync(10000) - await resultPromise - - expect(mockDel).toHaveBeenCalledWith("heartbeat:789") - expect(mockDel).toHaveBeenCalledWith("runners:789") - }) - - it("should handle docker ps failure gracefully", async () => { - const runId = 111 - - mockExecFileSync - .mockReturnValueOnce("") // controller kill succeeds - .mockImplementationOnce(() => { - throw new Error("Docker error") - }) // docker ps fails - - const resultPromise = killRun(runId) - await vi.advanceTimersByTimeAsync(10000) - const result = await resultPromise - - // Should still be successful because controller was killed - expect(result.success).toBe(true) - expect(result.killedContainers).toContain("evals-controller-111") - expect(result.errors).toContain("Failed to list Docker task containers") - }) - - it("should handle individual task kill failures", async () => { - const runId = 222 - - mockExecFileSync - .mockReturnValueOnce("") // controller kill - .mockReturnValueOnce("evals-task-222-300.0\nevals-task-222-400.0\n") // docker ps - .mockImplementationOnce(() => { - throw new Error("Kill failed") - }) // first task kill fails - .mockReturnValueOnce("") // second task kill succeeds - - const resultPromise = killRun(runId) - await vi.advanceTimersByTimeAsync(10000) - const result = await resultPromise - - expect(result.success).toBe(true) - expect(result.killedContainers).toContain("evals-controller-222") - expect(result.killedContainers).toContain("evals-task-222-400.0") - expect(result.errors.length).toBe(1) - expect(result.errors[0]).toContain("evals-task-222-300.0") - }) - - it("should return success with no containers when nothing is running", async () => { - const runId = 333 - - mockExecFileSync - .mockImplementationOnce(() => { - throw new Error("No such container") - }) // controller not running - .mockReturnValueOnce("") // no task containers - - const resultPromise = killRun(runId) - await vi.advanceTimersByTimeAsync(10000) - const result = await resultPromise - - expect(result.success).toBe(true) - expect(result.killedContainers).toHaveLength(0) - expect(result.errors).toHaveLength(0) - }) - - it("should only kill containers belonging to the specific run", async () => { - const runId = 555 - - mockExecFileSync - .mockReturnValueOnce("") // controller kill - .mockReturnValueOnce("evals-task-555-100.0\n") // docker ps - .mockReturnValueOnce("") // docker kill task - - const resultPromise = killRun(runId) - await vi.advanceTimersByTimeAsync(10000) - const result = await resultPromise - - expect(result.success).toBe(true) - // Verify execFileSync was called for docker ps with run-specific filter - expect(mockExecFileSync).toHaveBeenNthCalledWith( - 2, - "docker", - ["ps", "--format", "{{.Names}}", "--filter", "name=evals-task-555-"], - expect.any(Object), - ) - }) -}) diff --git a/apps/web-evals/src/actions/exercises.ts b/apps/web-evals/src/actions/exercises.ts deleted file mode 100644 index 17eb1ff085e..00000000000 --- a/apps/web-evals/src/actions/exercises.ts +++ /dev/null @@ -1,22 +0,0 @@ -"use server" - -import * as path from "path" -import { fileURLToPath } from "url" - -import { exerciseLanguages, listDirectories } from "@roo-code/evals" - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) // /apps/web-evals/src/actions - -const EVALS_REPO_PATH = path.resolve(__dirname, "../../../../../evals") - -export const getExercises = async () => { - const result = await Promise.all( - exerciseLanguages.map(async (language) => { - const languagePath = path.join(EVALS_REPO_PATH, language) - const exercises = await listDirectories(__dirname, languagePath) - return exercises.map((exercise) => `${language}/${exercise}`) - }), - ) - - return result.flat() -} diff --git a/apps/web-evals/src/actions/heartbeat.ts b/apps/web-evals/src/actions/heartbeat.ts deleted file mode 100644 index a74aa8ee64e..00000000000 --- a/apps/web-evals/src/actions/heartbeat.ts +++ /dev/null @@ -1,8 +0,0 @@ -"use server" - -import { redisClient } from "@/lib/server/redis" - -export const getHeartbeat = async (runId: number) => { - const redis = await redisClient() - return redis.get(`heartbeat:${runId}`) -} diff --git a/apps/web-evals/src/actions/runners.ts b/apps/web-evals/src/actions/runners.ts deleted file mode 100644 index 8b7e86b0f3e..00000000000 --- a/apps/web-evals/src/actions/runners.ts +++ /dev/null @@ -1,8 +0,0 @@ -"use server" - -import { redisClient } from "@/lib/server/redis" - -export const getRunners = async (runId: number) => { - const redis = await redisClient() - return redis.sMembers(`runners:${runId}`) -} diff --git a/apps/web-evals/src/actions/runs.ts b/apps/web-evals/src/actions/runs.ts deleted file mode 100644 index 9d213547cee..00000000000 --- a/apps/web-evals/src/actions/runs.ts +++ /dev/null @@ -1,369 +0,0 @@ -"use server" - -import * as path from "path" -import fs from "fs" -import { fileURLToPath } from "url" -import { spawn, execFileSync } from "child_process" - -import { revalidatePath } from "next/cache" -import pMap from "p-map" - -import { - type ExerciseLanguage, - exerciseLanguages, - createRun as _createRun, - deleteRun as _deleteRun, - updateRun as _updateRun, - getIncompleteRuns as _getIncompleteRuns, - deleteRunsByIds as _deleteRunsByIds, - createTask, - getExercisesForLanguage, -} from "@roo-code/evals" - -import { CreateRun } from "@/lib/schemas" -import { redisClient } from "@/lib/server/redis" - -// Storage base path for eval logs -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) { - const run = await _createRun({ - ...values, - timeout, - socketPath: "", // TODO: Get rid of this. - }) - - if (suite === "partial") { - for (const path of exercises) { - const [language, exercise] = path.split("/") - - if (!language || !exercise) { - throw new Error("Invalid exercise path: " + path) - } - - // Create multiple tasks for each iteration - for (let iteration = 1; iteration <= iterations; iteration++) { - await createTask({ - ...values, - runId: run.id, - language: language as ExerciseLanguage, - exercise, - iteration, - }) - } - } - } else { - for (const language of exerciseLanguages) { - const languageExercises = await getExercisesForLanguage(EVALS_REPO_PATH, language) - - // Create tasks for all iterations of each exercise - const tasksToCreate: Array<{ language: ExerciseLanguage; exercise: string; iteration: number }> = [] - for (const exercise of languageExercises) { - for (let iteration = 1; iteration <= iterations; iteration++) { - tasksToCreate.push({ language, exercise, iteration }) - } - } - - await pMap( - tasksToCreate, - ({ language, exercise, iteration }) => createTask({ runId: run.id, language, exercise, iteration }), - { concurrency: 10 }, - ) - } - } - - revalidatePath("/runs") - - try { - const isRunningInDocker = fs.existsSync("/.dockerenv") - - const dockerArgs = [ - `--name evals-controller-${run.id}`, - "--rm", - "--network evals_default", - "-v /var/run/docker.sock:/var/run/docker.sock", - "-v /tmp/evals:/var/log/evals", - "-e HOST_EXECUTION_METHOD=docker", - ] - - const cliCommand = `pnpm --filter @roo-code/evals cli --runId ${run.id}` - - const command = isRunningInDocker - ? `docker run ${dockerArgs.join(" ")} evals-runner sh -c "${cliCommand}"` - : cliCommand - - console.log("spawn ->", command) - - const childProcess = spawn("sh", ["-c", command], { - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }) - - const logStream = fs.createWriteStream("/tmp/roo-code-evals.log", { flags: "a" }) - - if (childProcess.stdout) { - childProcess.stdout.pipe(logStream) - } - - if (childProcess.stderr) { - childProcess.stderr.pipe(logStream) - } - - childProcess.unref() - } catch (error) { - console.error(error) - } - - return run -} - -export async function deleteRun(runId: number) { - await _deleteRun(runId) - revalidatePath("/runs") -} - -export type KillRunResult = { - success: boolean - killedContainers: string[] - errors: string[] -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -/** - * Kill all Docker containers associated with a run (controller and task runners). - * Kills the controller first, waits 10 seconds, then kills runners. - * Also clears Redis state for heartbeat and runners. - * - * Container naming conventions: - * - Controller: evals-controller-{runId} - * - Task runners: evals-task-{runId}-{taskId}.{attempt} - */ -export async function killRun(runId: number): Promise { - const killedContainers: string[] = [] - const errors: string[] = [] - const controllerPattern = `evals-controller-${runId}` - const taskPattern = `evals-task-${runId}-` - - try { - // Step 1: Kill the controller first - console.log(`Killing controller: ${controllerPattern}`) - try { - execFileSync("docker", ["kill", controllerPattern], { encoding: "utf-8", timeout: 10000 }) - killedContainers.push(controllerPattern) - console.log(`Killed controller container: ${controllerPattern}`) - } catch (_error) { - // Controller might not be running - that's ok, continue to kill runners - console.log(`Controller ${controllerPattern} not running or already stopped`) - } - - // Step 2: Wait 10 seconds before killing runners - console.log("Waiting 10 seconds before killing runners...") - await sleep(10000) - - // Step 3: Find and kill all task runner containers for THIS run only - let taskContainerNames: string[] = [] - - try { - const output = execFileSync("docker", ["ps", "--format", "{{.Names}}", "--filter", `name=${taskPattern}`], { - encoding: "utf-8", - timeout: 10000, - }) - taskContainerNames = output - .split("\n") - .map((name) => name.trim()) - .filter((name) => name.length > 0 && name.startsWith(taskPattern)) - } catch (error) { - console.error("Failed to list task containers:", error) - errors.push("Failed to list Docker task containers") - } - - // Kill each task runner container - for (const containerName of taskContainerNames) { - try { - execFileSync("docker", ["kill", containerName], { encoding: "utf-8", timeout: 10000 }) - killedContainers.push(containerName) - console.log(`Killed task container: ${containerName}`) - } catch (error) { - // Container might have already stopped - console.error(`Failed to kill container ${containerName}:`, error) - errors.push(`Failed to kill container: ${containerName}`) - } - } - - // Step 4: Clear Redis state - try { - const redis = await redisClient() - const heartbeatKey = `heartbeat:${runId}` - const runnersKey = `runners:${runId}` - - await redis.del(heartbeatKey) - await redis.del(runnersKey) - console.log(`Cleared Redis keys: ${heartbeatKey}, ${runnersKey}`) - } catch (error) { - console.error("Failed to clear Redis state:", error) - errors.push("Failed to clear Redis state") - } - } catch (error) { - console.error("Error in killRun:", error) - errors.push("Unexpected error while killing containers") - } - - revalidatePath(`/runs/${runId}`) - revalidatePath("/runs") - - return { - success: killedContainers.length > 0 || errors.length === 0, - killedContainers, - errors, - } -} - -export type DeleteIncompleteRunsResult = { - success: boolean - deletedCount: number - deletedRunIds: number[] - storageErrors: string[] -} - -/** - * Delete all incomplete runs (runs without a taskMetricsId/final score). - * Removes both database records and storage folders. - */ -export async function deleteIncompleteRuns(): Promise { - const storageErrors: string[] = [] - - // Get all incomplete runs - const incompleteRuns = await _getIncompleteRuns() - const runIds = incompleteRuns.map((run) => run.id) - - if (runIds.length === 0) { - return { - success: true, - deletedCount: 0, - deletedRunIds: [], - storageErrors: [], - } - } - - // Delete storage folders for each run - for (const runId of runIds) { - const storagePath = path.join(EVALS_STORAGE_PATH, String(runId)) - try { - if (fs.existsSync(storagePath)) { - fs.rmSync(storagePath, { recursive: true, force: true }) - console.log(`Deleted storage folder: ${storagePath}`) - } - } catch (error) { - console.error(`Failed to delete storage folder ${storagePath}:`, error) - storageErrors.push(`Failed to delete storage for run ${runId}`) - } - - // Also try to clear Redis state for any potentially running incomplete runs - try { - const redis = await redisClient() - await redis.del(`heartbeat:${runId}`) - await redis.del(`runners:${runId}`) - } catch (error) { - // Non-critical error, just log it - console.error(`Failed to clear Redis state for run ${runId}:`, error) - } - } - - // Delete from database - await _deleteRunsByIds(runIds) - - revalidatePath("/runs") - - return { - success: true, - deletedCount: runIds.length, - deletedRunIds: runIds, - storageErrors, - } -} - -/** - * Get count of incomplete runs (for UI display) - */ -export async function getIncompleteRunsCount(): Promise { - const incompleteRuns = await _getIncompleteRuns() - return incompleteRuns.length -} - -/** - * Delete all runs older than 30 days. - * Removes both database records and storage folders. - */ -export async function deleteOldRuns(): Promise { - const storageErrors: string[] = [] - - // Get all runs older than 30 days - const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) - const { getRuns } = await import("@roo-code/evals") - const allRuns = await getRuns() - const oldRuns = allRuns.filter((run) => run.createdAt < thirtyDaysAgo) - const runIds = oldRuns.map((run) => run.id) - - if (runIds.length === 0) { - return { - success: true, - deletedCount: 0, - deletedRunIds: [], - storageErrors: [], - } - } - - // Delete storage folders for each run - for (const runId of runIds) { - const storagePath = path.join(EVALS_STORAGE_PATH, String(runId)) - try { - if (fs.existsSync(storagePath)) { - fs.rmSync(storagePath, { recursive: true, force: true }) - console.log(`Deleted storage folder: ${storagePath}`) - } - } catch (error) { - console.error(`Failed to delete storage folder ${storagePath}:`, error) - storageErrors.push(`Failed to delete storage for run ${runId}`) - } - - // Also try to clear Redis state - try { - const redis = await redisClient() - await redis.del(`heartbeat:${runId}`) - await redis.del(`runners:${runId}`) - } catch (error) { - // Non-critical error, just log it - console.error(`Failed to clear Redis state for run ${runId}:`, error) - } - } - - // Delete from database - await _deleteRunsByIds(runIds) - - revalidatePath("/runs") - - return { - success: true, - deletedCount: runIds.length, - deletedRunIds: runIds, - storageErrors, - } -} - -/** - * Update the description of a run. - */ -export async function updateRunDescription(runId: number, description: string | null): Promise<{ success: boolean }> { - try { - await _updateRun(runId, { description }) - revalidatePath("/runs") - revalidatePath(`/runs/${runId}`) - return { success: true } - } catch (error) { - console.error("Failed to update run description:", error) - return { success: false } - } -} diff --git a/apps/web-evals/src/actions/tasks.ts b/apps/web-evals/src/actions/tasks.ts deleted file mode 100644 index 18b428b0cad..00000000000 --- a/apps/web-evals/src/actions/tasks.ts +++ /dev/null @@ -1,11 +0,0 @@ -"use server" - -import { revalidatePath } from "next/cache" - -import { getTasks as _getTasks } from "@roo-code/evals" - -export async function getTasks(runId: number) { - const tasks = await _getTasks(runId) - revalidatePath(`/runs/${runId}`) - return tasks -} diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts deleted file mode 100644 index e5ec8751ab0..00000000000 --- a/apps/web-evals/src/app/api/runs/[id]/logs/[taskId]/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { NextResponse } from "next/server" -import type { NextRequest } from "next/server" -import * as fs from "node:fs/promises" -import * as path from "node:path" - -import { findTask, findRun } from "@roo-code/evals" - -export const dynamic = "force-dynamic" - -const LOG_BASE_PATH = "/tmp/evals/runs" - -// Sanitize path components to prevent path traversal attacks -function sanitizePathComponent(component: string): string { - // Remove any path separators, null bytes, and other dangerous characters - return component.replace(/[/\\:\0*?"<>|]/g, "_") -} - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string; taskId: string }> }) { - const { id, taskId } = await params - - try { - const runId = Number(id) - const taskIdNum = Number(taskId) - - if (isNaN(runId) || isNaN(taskIdNum)) { - return NextResponse.json({ error: "Invalid run ID or task ID" }, { status: 400 }) - } - - // Verify the run exists - await findRun(runId) - - // Get the task to find its language and exercise - const task = await findTask(taskIdNum) - - // Verify the task belongs to this run - if (task.runId !== runId) { - return NextResponse.json({ error: "Task does not belong to this run" }, { status: 404 }) - } - - // Sanitize language and exercise to prevent path traversal - const safeLanguage = sanitizePathComponent(task.language) - const safeExercise = sanitizePathComponent(task.exercise) - - // Construct the log file path - const logFileName = `${safeLanguage}-${safeExercise}.log` - const logFilePath = path.join(LOG_BASE_PATH, String(runId), logFileName) - - // Verify the resolved path is within the expected directory (defense in depth) - const resolvedPath = path.resolve(logFilePath) - const expectedBase = path.resolve(LOG_BASE_PATH) - if (!resolvedPath.startsWith(expectedBase)) { - return NextResponse.json({ error: "Invalid log path" }, { status: 400 }) - } - - // Check if the log file exists and read it (async) - try { - const logContent = await fs.readFile(logFilePath, "utf-8") - return NextResponse.json({ logContent }) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 }) - } - throw err - } - } catch (error) { - console.error("Error reading task log:", error) - - if (error instanceof Error && error.name === "RecordNotFoundError") { - return NextResponse.json({ error: "Task or run not found" }, { status: 404 }) - } - - return NextResponse.json({ error: "Failed to read log file" }, { status: 500 }) - } -} diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts deleted file mode 100644 index 8b2760df987..00000000000 --- a/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { NextResponse } from "next/server" -import type { NextRequest } from "next/server" -import * as fs from "node:fs" -import * as path from "node:path" -import archiver from "archiver" - -import { findRun, getTasks } from "@roo-code/evals" - -export const dynamic = "force-dynamic" - -const LOG_BASE_PATH = "/tmp/evals/runs" - -// Sanitize path components to prevent path traversal attacks -function sanitizePathComponent(component: string): string { - // Remove any path separators, null bytes, and other dangerous characters - return component.replace(/[/\\:\0*?"<>|]/g, "_") -} - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - - try { - const runId = Number(id) - - if (isNaN(runId)) { - return NextResponse.json({ error: "Invalid run ID" }, { status: 400 }) - } - - // Verify the run exists - await findRun(runId) - - // Get all tasks for this run - const tasks = await getTasks(runId) - - // Filter for failed tasks only - const failedTasks = tasks.filter((task) => task.passed === false) - - if (failedTasks.length === 0) { - return NextResponse.json({ error: "No failed tasks to export" }, { status: 400 }) - } - - // Create a zip archive - const archive = archiver("zip", { zlib: { level: 9 } }) - - // Collect chunks to build the response - const chunks: Buffer[] = [] - - archive.on("data", (chunk: Buffer) => { - chunks.push(chunk) - }) - - // Track archive errors - let archiveError: Error | null = null - archive.on("error", (err: Error) => { - archiveError = err - }) - - // Set up the end promise before finalizing (proper event listener ordering) - const archiveEndPromise = new Promise((resolve, reject) => { - archive.on("end", resolve) - archive.on("error", reject) - }) - - // Add each failed task's log file and history files to the archive - const logDir = path.join(LOG_BASE_PATH, String(runId)) - let filesAdded = 0 - - for (const task of failedTasks) { - // Sanitize language and exercise to prevent path traversal - const safeLanguage = sanitizePathComponent(task.language) - const safeExercise = sanitizePathComponent(task.exercise) - const expectedBase = path.resolve(LOG_BASE_PATH) - - // Add the log file - const logFileName = `${safeLanguage}-${safeExercise}.log` - const logFilePath = path.join(logDir, logFileName) - - // Verify the resolved path is within the expected directory (defense in depth) - const resolvedLogPath = path.resolve(logFilePath) - if (resolvedLogPath.startsWith(expectedBase) && fs.existsSync(logFilePath)) { - archive.file(logFilePath, { name: logFileName }) - filesAdded++ - } - - // Add the API conversation history file - // Format: {language}-{exercise}.{iteration}_api_conversation_history.json - const apiHistoryFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_api_conversation_history.json` - const apiHistoryFilePath = path.join(logDir, apiHistoryFileName) - const resolvedApiHistoryPath = path.resolve(apiHistoryFilePath) - if (resolvedApiHistoryPath.startsWith(expectedBase) && fs.existsSync(apiHistoryFilePath)) { - archive.file(apiHistoryFilePath, { name: apiHistoryFileName }) - filesAdded++ - } - - // Add the UI messages file - // Format: {language}-{exercise}.{iteration}_ui_messages.json - const uiMessagesFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_ui_messages.json` - const uiMessagesFilePath = path.join(logDir, uiMessagesFileName) - const resolvedUiMessagesPath = path.resolve(uiMessagesFilePath) - if (resolvedUiMessagesPath.startsWith(expectedBase) && fs.existsSync(uiMessagesFilePath)) { - archive.file(uiMessagesFilePath, { name: uiMessagesFileName }) - filesAdded++ - } - } - - // Check if any files were actually added - if (filesAdded === 0) { - archive.abort() - return NextResponse.json( - { error: "No log files found - they may have been cleared from disk" }, - { status: 404 }, - ) - } - - // Finalize the archive - await archive.finalize() - - // Wait for all data to be collected - await archiveEndPromise - - // Check for archive errors - if (archiveError) { - throw archiveError - } - - // Combine all chunks into a single buffer - const zipBuffer = Buffer.concat(chunks) - - // Return the zip file - return new NextResponse(zipBuffer, { - status: 200, - headers: { - "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="run-${runId}-failed-logs.zip"`, - "Content-Length": String(zipBuffer.length), - }, - }) - } catch (error) { - console.error("Error exporting failed logs:", error) - - if (error instanceof Error && error.name === "RecordNotFoundError") { - return NextResponse.json({ error: "Run not found" }, { status: 404 }) - } - - return NextResponse.json({ error: "Failed to export logs" }, { status: 500 }) - } -} diff --git a/apps/web-evals/src/app/api/runs/[id]/stream/route.ts b/apps/web-evals/src/app/api/runs/[id]/stream/route.ts deleted file mode 100644 index 3168974ecd4..00000000000 --- a/apps/web-evals/src/app/api/runs/[id]/stream/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { NextRequest } from "next/server" - -import { taskEventSchema } from "@roo-code/types" -import { findRun } from "@roo-code/evals" - -import { SSEStream } from "@/lib/server/sse-stream" -import { redisClient } from "@/lib/server/redis" - -export const dynamic = "force-dynamic" - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const requestId = crypto.randomUUID() - const stream = new SSEStream() - const run = await findRun(Number(id)) - const redis = await redisClient() - - let isStreamClosed = false - const channelName = `evals:${run.id}` - - const onMessage = async (data: string) => { - if (isStreamClosed || stream.isClosed) { - return - } - - try { - const taskEvent = taskEventSchema.parse(JSON.parse(data)) - // console.log(`[stream#${requestId}] task event -> ${taskEvent.eventName}`) - const writeSuccess = await stream.write(JSON.stringify(taskEvent)) - - if (!writeSuccess) { - await disconnect() - } - } catch (_error) { - console.error(`[stream#${requestId}] invalid task event:`, data) - } - } - - const disconnect = async () => { - if (isStreamClosed) { - return - } - - isStreamClosed = true - - try { - await redis.unsubscribe(channelName) - console.log(`[stream#${requestId}] unsubscribed from ${channelName}`) - } catch (error) { - console.error(`[stream#${requestId}] error unsubscribing:`, error) - } - - try { - await stream.close() - } catch (error) { - console.error(`[stream#${requestId}] error closing stream:`, error) - } - } - - await redis.subscribe(channelName, onMessage) - - request.signal.addEventListener("abort", () => { - console.log(`[stream#${requestId}] abort`) - - disconnect().catch((error) => { - console.error(`[stream#${requestId}] cleanup error:`, error) - }) - }) - - return stream.getResponse() -} diff --git a/apps/web-evals/src/app/favicon.ico b/apps/web-evals/src/app/favicon.ico deleted file mode 100644 index 718d6fea483..00000000000 Binary files a/apps/web-evals/src/app/favicon.ico and /dev/null differ diff --git a/apps/web-evals/src/app/globals.css b/apps/web-evals/src/app/globals.css deleted file mode 100644 index 8c12f0d1d2c..00000000000 --- a/apps/web-evals/src/app/globals.css +++ /dev/null @@ -1,141 +0,0 @@ -@import "tailwindcss"; - -@plugin "tailwindcss-animate"; - -@custom-variant dark (&:is(.dark *)); - -:root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(23.66% 0.0198 271.79); - --foreground: oklch(75.15% 0.0477 278.41); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: var(--primary); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(29.33% 0.0295 276.18); - --primary-foreground: var(--accent); - --secondary: var(--primary); - --secondary-foreground: var(--foreground); - --muted: oklch(28.27% 0.0207 273.06); - --muted-foreground: oklch(75.15% 0.0477 278.41 / 75%); - --accent: oklch(70.21% 0.1813 328.71); - --accent-foreground: oklch(1 0 0 / 75%); - --destructive: oklch(72.14% 0.1616 15.49); - --border: var(--primary); - --input: var(--primary); - --ring: oklch(83.63% 0.1259 176.52); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - - --animate-hop: hop 0.8s ease-in-out infinite; - - @keyframes hop { - 0%, - 100% { - transform: none; - animation-timing-function: cubic-bezier(0.8, 0, 1, 1); - } - 50% { - transform: translateY(-8px); - animation-timing-function: cubic-bezier(0, 0, 0.2, 1); - } - } -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - html, - body { - height: 100%; - } - body { - @apply bg-background text-foreground; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* Firefox */ - scrollbar-width: thin; - } -} diff --git a/apps/web-evals/src/app/layout.tsx b/apps/web-evals/src/app/layout.tsx deleted file mode 100644 index 3bb34f7dfb8..00000000000 --- a/apps/web-evals/src/app/layout.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import type { Metadata } from "next" -import { Geist, Geist_Mono } from "next/font/google" - -import { ThemeProvider, ReactQueryProvider } from "@/components/providers" -import { Toaster } from "@/components/ui" -import { Header } from "@/components/layout/header" - -import "./globals.css" - -const fontSans = Geist({ variable: "--font-sans", subsets: ["latin"] }) -const fontMono = Geist_Mono({ variable: "--font-mono", subsets: ["latin"] }) - -export const metadata: Metadata = { - title: "Roo Code Evals", -} - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { - return ( - - - - -
- {children} - - - - - - ) -} diff --git a/apps/web-evals/src/app/page.tsx b/apps/web-evals/src/app/page.tsx deleted file mode 100644 index 3dcb26aebf2..00000000000 --- a/apps/web-evals/src/app/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { getRuns } from "@roo-code/evals" - -import { Runs } from "@/components/home/runs" - -export const dynamic = "force-dynamic" - -export default async function Page() { - const runs = await getRuns() - return -} diff --git a/apps/web-evals/src/app/runs/[id]/page.tsx b/apps/web-evals/src/app/runs/[id]/page.tsx deleted file mode 100644 index 8b993eec8a0..00000000000 --- a/apps/web-evals/src/app/runs/[id]/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { findRun } from "@roo-code/evals" - -import { Run } from "./run" - -export default async function Page({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params - const run = await findRun(Number(id)) - - return ( -
- -
- ) -} diff --git a/apps/web-evals/src/app/runs/[id]/run-status.tsx b/apps/web-evals/src/app/runs/[id]/run-status.tsx deleted file mode 100644 index e05b1b51ebe..00000000000 --- a/apps/web-evals/src/app/runs/[id]/run-status.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client" - -import { Link2, Link2Off, CheckCircle2 } from "lucide-react" -import type { RunStatus as _RunStatus } from "@/hooks/use-run-status" -import { cn } from "@/lib/utils" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui" - -function StreamIcon({ status }: { status: "connected" | "waiting" | "error" }) { - if (status === "connected") { - return - } - return -} - -export const RunStatus = ({ - runStatus: { sseStatus, heartbeat, runners = [] }, - isComplete = false, -}: { - runStatus: _RunStatus - isComplete?: boolean -}) => { - // For completed runs, show a simple "Complete" badge - if (isComplete) { - return ( - - -
- -
-
- - Run complete - -
- ) - } - - return ( - - -
- {/* Task Stream status icon */} - - - {/* Task Controller ID */} - {heartbeat ?? "-"} - - {/* Task Runners count */} - 0 ? "text-green-500" : "text-rose-500"}> - {runners.length > 0 ? `${runners.length}r` : "0r"} - -
-
- -
-
- - Task Stream: {sseStatus} -
-
- - Task Controller: {heartbeat ?? "dead"} -
-
- 0 ? "text-green-500" : "text-rose-500"}>● - Task Runners: {runners.length > 0 ? runners.length : "none"} -
- {runners.length > 0 && ( -
- {runners.map((runner) => ( -
{runner}
- ))} -
- )} -
-
-
- ) -} diff --git a/apps/web-evals/src/app/runs/[id]/run.tsx b/apps/web-evals/src/app/runs/[id]/run.tsx deleted file mode 100644 index badd77741e0..00000000000 --- a/apps/web-evals/src/app/runs/[id]/run.tsx +++ /dev/null @@ -1,1058 +0,0 @@ -"use client" - -import { useMemo, useState, useCallback, useEffect, Fragment } from "react" -import { toast } from "sonner" -import { LoaderCircle, FileText, Copy, Check, StopCircle, List, Layers } from "lucide-react" - -import type { Run, TaskMetrics as _TaskMetrics, Task } from "@roo-code/evals" -import type { ToolName } from "@roo-code/types" - -import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters" -import { useRunStatus } from "@/hooks/use-run-status" -import { killRun } from "@/actions/runs" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tooltip, - TooltipContent, - TooltipTrigger, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - ScrollArea, - Button, - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui" - -import { TaskStatus } from "./task-status" -import { RunStatus } from "./run-status" - -type TaskMetrics = Pick<_TaskMetrics, "tokensIn" | "tokensOut" | "tokensContext" | "duration" | "cost"> - -// Extended Task type with taskMetrics from useRunStatus -type TaskWithMetrics = Task & { taskMetrics: _TaskMetrics | null } - -type ToolUsageEntry = { attempts: number; failures: number } -type ToolUsage = Record - -// Generate abbreviation from tool name (e.g., "read_file" -> "RF", "list_code_definition_names" -> "LCDN") -function getToolAbbreviation(toolName: string): string { - return toolName - .split("_") - .map((word) => word[0]?.toUpperCase() ?? "") - .join("") -} - -// Pattern definitions for syntax highlighting -type HighlightPattern = { - pattern: RegExp - className: string - // If true, wraps the entire match; if a number, wraps that capture group - wrapGroup?: number -} - -const HIGHLIGHT_PATTERNS: HighlightPattern[] = [ - // Log levels - styled as badges - { pattern: /\|\s*(INFO)\s*\|/g, className: "text-green-400", wrapGroup: 1 }, - { pattern: /\|\s*(WARN|WARNING)\s*\|/g, className: "text-yellow-400", wrapGroup: 1 }, - { pattern: /\|\s*(ERROR)\s*\|/g, className: "text-red-400 font-semibold", wrapGroup: 1 }, - { pattern: /\|\s*(DEBUG)\s*\|/g, className: "text-gray-400", wrapGroup: 1 }, - // Task identifiers - important events - { - pattern: /(taskCreated|taskFocused|taskStarted|taskCompleted|taskAborted|taskResumable)/g, - className: "text-purple-400 font-medium", - }, - // Tool failures - highlight in red - { pattern: /(taskToolFailed)/g, className: "text-red-400 font-bold" }, - { pattern: /(Tool execution failed|tool.*failed|failed.*tool)/gi, className: "text-red-400" }, - { pattern: /(EvalPass)/g, className: "text-green-400 font-bold" }, - { pattern: /(EvalFail)/g, className: "text-red-400 font-bold" }, - // Message arrows - { pattern: /→/g, className: "text-cyan-400" }, - // Tool names in quotes - { pattern: /"(tool)":\s*"([^"]+)"/g, className: "text-orange-400" }, - // JSON keys - { pattern: /"([^"]+)":/g, className: "text-sky-300" }, - // Boolean values - { pattern: /:\s*(true|false)/g, className: "text-amber-400", wrapGroup: 1 }, - // Numbers - { pattern: /:\s*(-?\d+\.?\d*)/g, className: "text-emerald-400", wrapGroup: 1 }, -] - -// Extract timestamp from a log line and return elapsed time from baseline -function formatElapsedTime(timestamp: string, baselineMs: number): string { - const currentMs = new Date(timestamp).getTime() - const elapsedMs = currentMs - baselineMs - const totalSeconds = Math.floor(elapsedMs / 1000) - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}` -} - -// Extract the first timestamp from the log to use as baseline -function extractFirstTimestamp(log: string): number | null { - // Match timestamp at start of line: [2025-11-28T09:35:23.187Z | ... or [2025-11-28T09:35:23.187Z] - const match = log.match(/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)[\s|\]]/) - const isoString = match?.[1] - if (!isoString) return null - return new Date(isoString).getTime() -} - -// Simplify log line by removing redundant metadata -function simplifyLogLine(line: string, baselineMs: number | null): { timestamp: string; simplified: string } { - // Extract timestamp - matches [2025-11-28T09:35:23.187Z | ... format - const timestampMatch = line.match(/\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)[\s|\]]/) - const isoTimestamp = timestampMatch?.[1] - if (!isoTimestamp) { - return { timestamp: "", simplified: line } - } - - const timestamp = baselineMs !== null ? formatElapsedTime(isoTimestamp, baselineMs) : isoTimestamp.slice(11, 19) - - // Remove the timestamp from the line (handles both [timestamp] and [timestamp | formats) - let simplified = line.replace(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s*\|?\s*/, "") - - // Remove redundant metadata: pid, run, task IDs (they're same for entire log) - simplified = simplified.replace(/\|\s*pid:\d+\s*/g, "") - simplified = simplified.replace(/\|\s*run:\d+\s*/g, "") - simplified = simplified.replace(/\|\s*task:\d+\s*/g, "") - simplified = simplified.replace(/runTask\s*\|\s*/g, "") - - // Clean up extra pipes, spaces, and trailing brackets - simplified = simplified.replace(/\|\s*\|/g, "|") - simplified = simplified.replace(/^\s*\|\s*/, "") - simplified = simplified.replace(/\]\s*$/, "") // Remove trailing bracket if present - - return { timestamp, simplified } -} - -// Format a single line with syntax highlighting using React elements (XSS-safe) -function formatLine(line: string): React.ReactNode[] { - // Find all matches with their positions - type Match = { start: number; end: number; text: string; className: string } - const matches: Match[] = [] - - for (const { pattern, className, wrapGroup } of HIGHLIGHT_PATTERNS) { - // Reset regex state - pattern.lastIndex = 0 - let regexMatch - while ((regexMatch = pattern.exec(line)) !== null) { - const capturedText = wrapGroup !== undefined ? regexMatch[wrapGroup] : regexMatch[0] - // Skip if capture group didn't match - if (!capturedText) continue - const start = - wrapGroup !== undefined ? regexMatch.index + regexMatch[0].indexOf(capturedText) : regexMatch.index - matches.push({ - start, - end: start + capturedText.length, - text: capturedText, - className, - }) - } - } - - // Sort matches by position and filter overlapping ones - matches.sort((a, b) => a.start - b.start) - const filteredMatches: Match[] = [] - for (const m of matches) { - const lastMatch = filteredMatches[filteredMatches.length - 1] - if (!lastMatch || m.start >= lastMatch.end) { - filteredMatches.push(m) - } - } - - // Build result with highlighted spans - const result: React.ReactNode[] = [] - let currentPos = 0 - - for (const [i, m] of filteredMatches.entries()) { - // Add text before this match - if (m.start > currentPos) { - result.push(line.slice(currentPos, m.start)) - } - // Add highlighted match - result.push( - - {m.text} - , - ) - currentPos = m.end - } - - // Add remaining text - if (currentPos < line.length) { - result.push(line.slice(currentPos)) - } - - return result.length > 0 ? result : [line] -} - -// Determine the visual style for a log line based on its content -function getLineStyle(line: string): string { - if (line.includes("ERROR")) return "bg-red-950/30 border-l-2 border-red-500" - if (line.includes("WARN") || line.includes("WARNING")) return "bg-yellow-950/20 border-l-2 border-yellow-500" - if (line.includes("taskToolFailed")) return "bg-red-950/30 border-l-2 border-red-500" - if (line.includes("taskStarted") || line.includes("taskCreated")) return "bg-purple-950/20" - if (line.includes("EvalPass")) return "bg-green-950/30 border-l-2 border-green-500" - if (line.includes("EvalFail")) return "bg-red-950/30 border-l-2 border-red-500" - if (line.includes("taskCompleted") || line.includes("taskAborted")) return "bg-blue-950/20" - return "" -} - -// Format log content with basic highlighting (XSS-safe - no dangerouslySetInnerHTML) -function formatLogContent(log: string): React.ReactNode[] { - const lines = log.split("\n") - const baselineMs = extractFirstTimestamp(log) - - return lines.map((line, index) => { - if (!line.trim()) { - return ( -
- {" "} -
- ) - } - - const parsed = simplifyLogLine(line, baselineMs) - const lineStyle = getLineStyle(line) - - return ( -
- {/* Elapsed time */} - - {parsed.timestamp} - - {/* Log content - pl-12 ensures wrapped lines are indented under the timestamp */} - - {formatLine(parsed.simplified)} - -
- ) - }) -} - -export function Run({ run }: { run: Run }) { - const runStatus = useRunStatus(run) - const { tasks, tokenUsage, toolUsage, usageUpdatedAt, heartbeat, runners } = runStatus - - const [selectedTask, setSelectedTask] = useState(null) - const [taskLog, setTaskLog] = useState(null) - const [isLoadingLog, setIsLoadingLog] = useState(false) - const [copied, setCopied] = useState(false) - const [showKillDialog, setShowKillDialog] = useState(false) - const [isKilling, setIsKilling] = useState(false) - const [groupByStatus, setGroupByStatus] = useState(() => { - // Initialize from localStorage if available (client-side only) - if (typeof window !== "undefined") { - const stored = localStorage.getItem("evals-group-by-status") - return stored === "true" - } - return false - }) - - // Persist groupByStatus to localStorage - useEffect(() => { - localStorage.setItem("evals-group-by-status", String(groupByStatus)) - }, [groupByStatus]) - - // Determine if run is still active (has heartbeat or runners) - const isRunActive = !run.taskMetricsId && (!!heartbeat || (runners && runners.length > 0)) - - const onKillRun = useCallback(async () => { - setIsKilling(true) - try { - const result = await killRun(run.id) - if (result.killedContainers.length > 0) { - toast.success(`Killed ${result.killedContainers.length} container(s)`) - } else if (result.errors.length === 0) { - toast.info("No running containers found") - } else { - toast.error(result.errors.join(", ")) - } - } catch (error) { - console.error("Failed to kill run:", error) - toast.error("Failed to kill run") - } finally { - setIsKilling(false) - setShowKillDialog(false) - } - }, [run.id]) - - const onCopyLog = useCallback(async () => { - if (!taskLog) return - - try { - await navigator.clipboard.writeText(taskLog) - setCopied(true) - toast.success("Log copied to clipboard") - setTimeout(() => setCopied(false), 2000) - } catch (error) { - console.error("Failed to copy log:", error) - toast.error("Failed to copy log") - } - }, [taskLog]) - - // Handle ESC key to close the dialog - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && selectedTask) { - setSelectedTask(null) - } - } - - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [selectedTask]) - - const taskMetrics: Record = useMemo(() => { - // Reference usageUpdatedAt to trigger recomputation when Map contents change - void usageUpdatedAt - const metrics: Record = {} - - // Helper to calculate duration from database timestamps when streaming duration - // is unavailable (e.g., page was loaded after TaskStarted event was published) - const calculateDurationFromTimestamps = (task: TaskWithMetrics): number => { - if (!task.startedAt) return 0 - const startTime = new Date(task.startedAt).getTime() - const endTime = task.finishedAt ? new Date(task.finishedAt).getTime() : Date.now() - return endTime - startTime - } - - tasks?.forEach((task) => { - const streamingUsage = tokenUsage.get(task.id) - const dbMetrics = task.taskMetrics - - // For finished tasks, prefer DB values but fall back to streaming values - // This handles race conditions during timeout where DB might not have latest data - if (task.finishedAt) { - // Check if DB metrics have meaningful values (not just default/empty) - const dbHasData = dbMetrics && (dbMetrics.tokensIn > 0 || dbMetrics.tokensOut > 0 || dbMetrics.cost > 0) - if (dbHasData) { - // If DB duration is 0 but we have timestamps, calculate from timestamps - const duration = dbMetrics.duration || calculateDurationFromTimestamps(task) - metrics[task.id] = { ...dbMetrics, duration } - } else if (streamingUsage) { - // Fall back to streaming values if DB is empty/stale - // Use streaming duration, or calculate from timestamps if not available - const duration = streamingUsage.duration || calculateDurationFromTimestamps(task) - metrics[task.id] = { - tokensIn: streamingUsage.totalTokensIn, - tokensOut: streamingUsage.totalTokensOut, - tokensContext: streamingUsage.contextTokens, - duration, - cost: streamingUsage.totalCost, - } - } else { - // Task finished but no DB metrics and no streaming data - // (e.g., page loaded after task completed, metrics not persisted) - // Still provide duration calculated from timestamps - metrics[task.id] = { - tokensIn: 0, - tokensOut: 0, - tokensContext: 0, - duration: calculateDurationFromTimestamps(task), - cost: 0, - } - } - } else if (streamingUsage) { - // For running tasks, use streaming values - // Use streaming duration, or calculate from task.startedAt if not available - // (happens when page loads after TaskStarted event was already published) - const duration = streamingUsage.duration || calculateDurationFromTimestamps(task) - metrics[task.id] = { - tokensIn: streamingUsage.totalTokensIn, - tokensOut: streamingUsage.totalTokensOut, - tokensContext: streamingUsage.contextTokens, - duration, - cost: streamingUsage.totalCost, - } - } else if (task.startedAt) { - // Task has started (has startedAt in DB) but no streaming data yet - // This can happen when page loads after TaskStarted but before TokenUsageUpdated - metrics[task.id] = { - tokensIn: 0, - tokensOut: 0, - tokensContext: 0, - duration: calculateDurationFromTimestamps(task), - cost: 0, - } - } - }) - - return metrics - }, [tasks, tokenUsage, usageUpdatedAt]) - - const onViewTaskLog = useCallback( - async (task: Task) => { - // Only allow viewing logs for tasks that have started. - // Note: we treat presence of derived metrics as evidence of a started task, - // since this page may be rendered without streaming `tokenUsage` populated. - const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] - if (!hasStarted) { - toast.error("Task has not started yet") - return - } - - setSelectedTask(task) - setIsLoadingLog(true) - setTaskLog(null) - - try { - const response = await fetch(`/api/runs/${run.id}/logs/${task.id}`) - - if (!response.ok) { - const error = await response.json() - toast.error(error.error || "Failed to load log") - setSelectedTask(null) - return - } - - const data = await response.json() - setTaskLog(data.logContent) - } catch (error) { - console.error("Error loading task log:", error) - toast.error("Failed to load log") - setSelectedTask(null) - } finally { - setIsLoadingLog(false) - } - }, - [run.id, tokenUsage, taskMetrics], - ) - - // Collect all unique tool names from all tasks and sort by total attempts - const toolColumns = useMemo(() => { - // Reference usageUpdatedAt to trigger recomputation when Map contents change - void usageUpdatedAt - if (!tasks) return [] - - const toolTotals = new Map() - - for (const task of tasks) { - // Get both DB and streaming values - const dbToolUsage = task.taskMetrics?.toolUsage - const streamingToolUsage = toolUsage.get(task.id) - - // For finished tasks, prefer DB values but fall back to streaming values - // For running tasks, use streaming values - // This handles race conditions during timeout where DB might not have latest data - const taskToolUsage = task.finishedAt - ? dbToolUsage && Object.keys(dbToolUsage).length > 0 - ? dbToolUsage - : streamingToolUsage - : streamingToolUsage - - if (taskToolUsage) { - for (const [toolName, usage] of Object.entries(taskToolUsage)) { - const tool = toolName as ToolName - const current = toolTotals.get(tool) ?? 0 - toolTotals.set(tool, current + usage.attempts) - } - } - } - - // Sort by total attempts descending - return Array.from(toolTotals.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([name]): ToolName => name) - // toolUsage ref is stable; usageUpdatedAt triggers recomputation when Map contents change - }, [tasks, toolUsage, usageUpdatedAt]) - - // Compute aggregate stats - const stats = useMemo(() => { - // Reference usageUpdatedAt to trigger recomputation when Map contents change - void usageUpdatedAt - if (!tasks) return null - - const passed = tasks.filter((t) => t.passed === true).length - const failed = tasks.filter((t) => t.passed === false).length - const completed = passed + failed - - let totalTokensIn = 0 - let totalTokensOut = 0 - let totalCost = 0 - let totalDuration = 0 - - // Aggregate tool usage from all tasks (both finished and running) - const toolUsageAggregate: ToolUsage = {} - - for (const task of tasks) { - const metrics = taskMetrics[task.id] - if (metrics) { - totalTokensIn += metrics.tokensIn - totalTokensOut += metrics.tokensOut - totalCost += metrics.cost - totalDuration += metrics.duration - } - - // Aggregate tool usage: prefer DB values for finished tasks, fall back to streaming values - // This handles race conditions during timeout where DB might not have latest data - const dbToolUsage = task.taskMetrics?.toolUsage - const streamingToolUsage = toolUsage.get(task.id) - const taskToolUsage = task.finishedAt - ? dbToolUsage && Object.keys(dbToolUsage).length > 0 - ? dbToolUsage - : streamingToolUsage - : streamingToolUsage - - if (taskToolUsage) { - for (const [key, usage] of Object.entries(taskToolUsage)) { - const tool = key as keyof ToolUsage - if (!toolUsageAggregate[tool]) { - toolUsageAggregate[tool] = { attempts: 0, failures: 0 } - } - toolUsageAggregate[tool].attempts += usage.attempts - toolUsageAggregate[tool].failures += usage.failures - } - } - } - - const remaining = tasks.length - completed - - return { - passed, - failed, - completed, - remaining, - passRate: completed > 0 ? ((passed / completed) * 100).toFixed(1) : null, - totalTokensIn, - totalTokensOut, - totalCost, - totalDuration, - toolUsage: toolUsageAggregate, - } - // Map refs are stable; usageUpdatedAt triggers recomputation when Map contents change - }, [tasks, taskMetrics, toolUsage, usageUpdatedAt]) - - // Calculate elapsed time (wall-clock time from run creation to completion or now) - const elapsedTime = useMemo(() => { - // Reference usageUpdatedAt to trigger recomputation for live elapsed time updates - void usageUpdatedAt - if (!tasks || tasks.length === 0) return null - - const startTime = new Date(run.createdAt).getTime() - - // If run is complete, find the latest finishedAt from tasks - if (run.taskMetricsId) { - const latestFinish = tasks.reduce((latest, task) => { - if (task.finishedAt) { - const finishTime = new Date(task.finishedAt).getTime() - return finishTime > latest ? finishTime : latest - } - return latest - }, startTime) - return latestFinish - startTime - } - - // If still running, use current time - return Date.now() - startTime - }, [tasks, run.createdAt, run.taskMetricsId, usageUpdatedAt]) - - // Task status categories - type TaskStatusCategory = "failed" | "in_progress" | "passed" | "not_started" - - const getTaskStatusCategory = useCallback( - (task: TaskWithMetrics): TaskStatusCategory => { - if (task.passed === false) return "failed" - if (task.passed === true) return "passed" - // Check streaming data, DB metrics, or startedAt timestamp - const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] - if (hasStarted) return "in_progress" - return "not_started" - }, - [tokenUsage, taskMetrics], - ) - - // Group tasks by status while preserving original index - const groupedTasks = useMemo(() => { - if (!tasks || !groupByStatus) return null - - const groups: Record> = { - failed: [], - in_progress: [], - passed: [], - not_started: [], - } - - tasks.forEach((task, index) => { - const status = getTaskStatusCategory(task) - groups[status].push({ task, originalIndex: index }) - }) - - return groups - }, [tasks, groupByStatus, getTaskStatusCategory]) - - const statusLabels = useMemo( - (): Record => ({ - failed: { label: "Failed", className: "text-red-500", count: groupedTasks?.failed.length ?? 0 }, - in_progress: { - label: "In Progress", - className: "text-yellow-500", - count: groupedTasks?.in_progress.length ?? 0, - }, - passed: { label: "Passed", className: "text-green-500", count: groupedTasks?.passed.length ?? 0 }, - not_started: { - label: "Not Started", - className: "text-muted-foreground", - count: groupedTasks?.not_started.length ?? 0, - }, - }), - [groupedTasks], - ) - - const statusOrder: TaskStatusCategory[] = ["failed", "in_progress", "passed", "not_started"] - - // Helper to render a task row - const renderTaskRow = (task: TaskWithMetrics, originalIndex: number) => { - const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] - return ( - hasStarted && onViewTaskLog(task)}> - - {originalIndex + 1} - - -
- -
- - {task.language}/{task.exercise} - {task.iteration > 1 && ( - (#{task.iteration}) - )} - - {hasStarted && ( - - - - - Click to view log - - )} -
-
-
- {taskMetrics[task.id] ? ( - <> - -
-
{formatTokens(taskMetrics[task.id]!.tokensIn)}
/ -
{formatTokens(taskMetrics[task.id]!.tokensOut)}
-
-
- - {formatTokens(taskMetrics[task.id]!.tokensContext)} - - {toolColumns.map((toolName) => { - const dbUsage = task.taskMetrics?.toolUsage?.[toolName] - const streamingUsage = toolUsage.get(task.id)?.[toolName] - const usage = task.finishedAt ? (dbUsage ?? streamingUsage) : streamingUsage - - const successRate = - usage && usage.attempts > 0 - ? ((usage.attempts - usage.failures) / usage.attempts) * 100 - : 100 - const rateColor = - successRate === 100 - ? "text-muted-foreground" - : successRate >= 80 - ? "text-yellow-500" - : "text-red-500" - return ( - - {usage ? ( -
- {usage.attempts} - {formatToolUsageSuccessRate(usage)} -
- ) : ( - - - )} -
- ) - })} - - {taskMetrics[task.id]!.duration ? formatDuration(taskMetrics[task.id]!.duration) : "-"} - - - {formatCurrency(taskMetrics[task.id]!.cost)} - - - ) : ( - - )} -
- ) - } - - return ( - <> -
- {!tasks ? ( - - ) : ( - <> - {/* View Toggle */} -
- - - - - - {groupByStatus ? "Show tasks in run order" : "Group tasks by status"} - - -
- - - {stats && ( - - - {/* Provider, Model title and status */} -
- {run.settings?.apiProvider && ( - - {run.settings.apiProvider} - - )} -
{run.model}
- - {run.description && ( - - - {run.description} - - )} - {isRunActive && ( - - - - - - Stop all containers for this run - - - )} -
- {/* Main Stats Row */} -
- {/* Pass Rate / Fail Rate / Remaining % */} -
-
- - {stats.completed > 0 - ? `${((stats.passed / stats.completed) * 100).toFixed(1)}%` - : "-"} - - / - - {stats.completed > 0 - ? `${((stats.failed / stats.completed) * 100).toFixed(1)}%` - : "-"} - - / - - {tasks.length > 0 - ? `${((stats.remaining / tasks.length) * 100).toFixed(1)}%` - : "-"} - -
-
- {stats.passed} - {" / "} - {stats.failed} - {" / "} - {stats.remaining} - {" of "} - {tasks.length} -
-
- - {/* Tokens */} -
-
- {formatTokens(stats.totalTokensIn)} - / - {formatTokens(stats.totalTokensOut)} -
-
Tokens In / Out
-
- - {/* Cost */} -
-
- {formatCurrency(stats.totalCost)} -
-
Cost
-
- - {/* Duration */} -
-
- {stats.totalDuration > 0 - ? formatDuration(stats.totalDuration) - : "-"} -
-
Duration
-
- - {/* Elapsed Time */} -
-
- {elapsedTime !== null ? formatDuration(elapsedTime) : "-"} -
-
Elapsed
-
- - {/* Estimated Time Remaining - only show if run is active and we have data */} - {!run.taskMetricsId && - elapsedTime !== null && - stats.completed > 0 && - stats.remaining > 0 && ( -
-
- ~ - {formatDuration( - (elapsedTime / stats.completed) * stats.remaining, - )} -
-
- Est. Remaining -
-
- )} -
- - {/* Tool Usage Row */} - {Object.keys(stats.toolUsage).length > 0 && ( -
- {Object.entries(stats.toolUsage) - .sort(([, a], [, b]) => b.attempts - a.attempts) - .map(([toolName, usage]) => { - const abbr = getToolAbbreviation(toolName) - const successRate = - usage.attempts > 0 - ? ((usage.attempts - usage.failures) / - usage.attempts) * - 100 - : 100 - const rateColor = - successRate === 100 - ? "text-green-500" - : successRate >= 80 - ? "text-yellow-500" - : "text-red-500" - return ( - - -
- - {abbr} - - - {usage.attempts} - - - {formatToolUsageSuccessRate(usage)} - -
-
- - {toolName} - -
- ) - })} -
- )} -
-
- )} - - # - Exercise - Tokens In / Out - Context - {toolColumns.map((toolName) => ( - - - {getToolAbbreviation(toolName)} - {toolName} - - - ))} - Duration - Cost - -
- - {groupByStatus && groupedTasks - ? // Grouped view - statusOrder.map((status) => { - const group = groupedTasks[status] - if (group.length === 0) return null - const { label, className } = statusLabels[status] - return ( - - - - - {label} ({group.length}) - - - - {group.map(({ task, originalIndex }) => - renderTaskRow(task, originalIndex), - )} - - ) - }) - : // Default order view - tasks.map((task, index) => renderTaskRow(task, index))} - -
- - )} -
- - {/* Task Log Dialog - Full Screen */} - setSelectedTask(null)}> - - -
- - - {selectedTask?.language}/{selectedTask?.exercise} - {selectedTask?.iteration && selectedTask.iteration > 1 && ( - (#{selectedTask.iteration}) - )} - - ( - {selectedTask?.passed === true - ? "Passed" - : selectedTask?.passed === false - ? "Failed" - : "Running"} - ) - - - {taskLog && ( - - )} -
-
-
- {isLoadingLog ? ( -
- -
- ) : taskLog ? ( - -
- {formatLogContent(taskLog)} -
-
- ) : ( -
- Log file not available (may have been cleared) -
- )} -
-
-
- - {/* Kill Run Confirmation Dialog */} - - - - Kill Run? - - This will stop the controller and all task runner containers for this run. Any running tasks - will be terminated immediately. This action cannot be undone. - - - - Cancel - - {isKilling ? ( - <> - - Killing... - - ) : ( - "Kill Run" - )} - - - - - - ) -} diff --git a/apps/web-evals/src/app/runs/[id]/task-status.tsx b/apps/web-evals/src/app/runs/[id]/task-status.tsx deleted file mode 100644 index bae785131a5..00000000000 --- a/apps/web-evals/src/app/runs/[id]/task-status.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { CircleCheck, CircleDashed, CircleSlash, LoaderCircle } from "lucide-react" - -import type { Task } from "@roo-code/evals" - -type TaskStatusProps = { - task: Task - running: boolean -} - -export const TaskStatus = ({ task, running }: TaskStatusProps) => { - return task.passed === false ? ( - - ) : task.passed === true ? ( - - ) : running ? ( - - ) : ( - - ) -} diff --git a/apps/web-evals/src/app/runs/new/new-run.tsx b/apps/web-evals/src/app/runs/new/new-run.tsx deleted file mode 100644 index be015ac8ca3..00000000000 --- a/apps/web-evals/src/app/runs/new/new-run.tsx +++ /dev/null @@ -1,1005 +0,0 @@ -"use client" - -import { useCallback, useEffect, useMemo, useState } from "react" -import { useRouter } from "next/navigation" -import { z } from "zod" -import { useQuery } from "@tanstack/react-query" -import { useForm, FormProvider } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { toast } from "sonner" -import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Info, Plus, Minus } from "lucide-react" - -import { - globalSettingsSchema, - providerSettingsSchema, - EVALS_SETTINGS, - getModelId, - type ProviderSettings, - type GlobalSettings, -} from "@roo-code/types" - -import { createRun } from "@/actions/runs" -import { getExercises } from "@/actions/exercises" - -import { - type CreateRun, - createRunSchema, - CONCURRENCY_MIN, - CONCURRENCY_MAX, - CONCURRENCY_DEFAULT, - TIMEOUT_MIN, - TIMEOUT_MAX, - TIMEOUT_DEFAULT, - ITERATIONS_MIN, - ITERATIONS_MAX, - ITERATIONS_DEFAULT, -} from "@/lib/schemas" -import { cn } from "@/lib/utils" - -import { useOpenRouterModels } from "@/hooks/use-open-router-models" -import { useRooCodeCloudModels } from "@/hooks/use-roo-code-cloud-models" - -import { - Button, - Checkbox, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - Input, - Textarea, - Tabs, - TabsList, - TabsTrigger, - MultiSelect, - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - Popover, - PopoverContent, - PopoverTrigger, - Slider, - Label, - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui" - -import { SettingsDiff } from "./settings-diff" - -type ImportedSettings = { - apiConfigs: Record - globalSettings: GlobalSettings - 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 - popoverOpen: boolean -} - -export function NewRun() { - const router = useRouter() - - const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("other") - 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 }, - ]) - - const openRouter = useOpenRouterModels() - const rooCodeCloud = useRooCodeCloudModels() - const models = provider === "openrouter" ? openRouter.data : rooCodeCloud.data - const searchValue = provider === "openrouter" ? openRouter.searchValue : rooCodeCloud.searchValue - const setSearchValue = provider === "openrouter" ? openRouter.setSearchValue : rooCodeCloud.setSearchValue - const onFilter = provider === "openrouter" ? openRouter.onFilter : rooCodeCloud.onFilter - - const exercises = useQuery({ queryKey: ["getExercises"], queryFn: () => getExercises() }) - - // State for selected exercises (needed for language toggle buttons) - const [selectedExercises, setSelectedExercises] = useState([]) - - const form = useForm({ - resolver: zodResolver(createRunSchema), - defaultValues: { - model: "", - description: "", - suite: "full", - exercises: [], - settings: undefined, - concurrency: CONCURRENCY_DEFAULT, - timeout: TIMEOUT_DEFAULT, - iterations: ITERATIONS_DEFAULT, - jobToken: "", - }, - }) - - const { - setValue, - clearErrors, - watch, - formState: { isSubmitting }, - } = form - - 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") - if (savedExercises) { - try { - const parsed = JSON.parse(savedExercises) as string[] - if (Array.isArray(parsed)) { - setSelectedExercises(parsed) - setValue("exercises", parsed) - } - } catch { - // Invalid JSON, ignore - } - } - } - }, [setValue]) - - // Extract unique languages from exercises - const languages = useMemo(() => { - if (!exercises.data) return [] - const langs = new Set() - for (const path of exercises.data) { - const lang = path.split("/")[0] - 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 [] - 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) - } - - setSelectedExercises(newSelected) - setValue("exercises", newSelected) - localStorage.setItem("evals-exercises", JSON.stringify(newSelected)) - }, - [getExercisesForLanguage, selectedExercises, setValue], - ) - - // Check if all exercises for a language are selected - const isLanguageSelected = useCallback( - (lang: string) => { - const langExercises = getExercisesForLanguage(lang) - return langExercises.length > 0 && langExercises.every((ex) => selectedExercises.includes(ex)) - }, - [getExercisesForLanguage, selectedExercises], - ) - - // Check if some (but not all) exercises for a language are selected - const isLanguagePartiallySelected = useCallback( - (lang: string) => { - const langExercises = getExercisesForLanguage(lang) - const selectedCount = langExercises.filter((ex) => selectedExercises.includes(ex)).length - return selectedCount > 0 && selectedCount < langExercises.length - }, - [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) - 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) - if (importedSettings) { - const providerSettings = importedSettings.apiConfigs[configName] ?? {} - setValue("model", getModelId(providerSettings) ?? "") - setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...importedSettings.globalSettings }) - } - }, - [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))) - }, []) - - 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 }) - } - } - } - - if (selectionsToLaunch.length === 0) { - toast.error("Please select at least one model or config") - 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) - if (i > 0) { - await new Promise((resolve) => setTimeout(resolve, 20000)) - } - - const runValues = { ...values } - - if (provider === "openrouter") { - runValues.model = selection.model - runValues.settings = { - ...(runValues.settings || {}), - apiProvider: "openrouter", - openRouterModelId: selection.model, - toolProtocol: useNativeToolProtocol ? "native" : "xml", - commandExecutionTimeout, - terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000, - } - } else if (provider === "roo") { - runValues.model = selection.model - runValues.settings = { - ...(runValues.settings || {}), - apiProvider: "roo", - apiModelId: selection.model, - toolProtocol: useNativeToolProtocol ? "native" : "xml", - commandExecutionTimeout, - terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000, - } - } else if (provider === "other" && selection.configName && importedSettings) { - const providerSettings = importedSettings.apiConfigs[selection.configName] ?? {} - runValues.model = getModelId(providerSettings) ?? "" - runValues.settings = { - ...EVALS_SETTINGS, - ...providerSettings, - ...importedSettings.globalSettings, - toolProtocol: useNativeToolProtocol ? "native" : "xml", - commandExecutionTimeout, - terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000, - } - } - - try { - await createRun(runValues) - toast.success(`Run ${i + 1}/${totalRuns} launched`) - } catch (e) { - toast.error(`Run ${i + 1} failed: ${e instanceof Error ? e.message : "Unknown error"}`) - } - } - - // Navigate back to main evals UI - router.push("/") - } catch (e) { - toast.error(e instanceof Error ? e.message : "An unknown error occurred.") - } - }, - [ - provider, - modelSelections, - configSelections, - importedSettings, - router, - useNativeToolProtocol, - commandExecutionTimeout, - terminalShellIntegrationTimeout, - ], - ) - - const onImportSettings = useCallback( - async (event: React.ChangeEvent) => { - const file = event.target.files?.[0] - - if (!file) { - return - } - - clearErrors("settings") - - try { - const { providerProfiles, globalSettings } = z - .object({ - providerProfiles: z.object({ - currentApiConfigName: z.string(), - apiConfigs: z.record(z.string(), providerSettingsSchema), - }), - globalSettings: globalSettingsSchema, - }) - .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 }) - - event.target.value = "" - } catch (e) { - console.error(e) - toast.error(e instanceof Error ? e.message : "An unknown error occurred.") - } - }, - [clearErrors, setValue], - ) - - return ( - <> - -
- ( - - setModelSource(value as "roo" | "openrouter" | "other")}> - - Import - Roo Code Cloud - OpenRouter - - - - {provider === "other" ? ( -
- - - - {importedSettings && Object.keys(importedSettings.apiConfigs).length > 0 && ( -
- - {configSelections.map((selection, index) => ( -
- - toggleConfigPopover(selection.id, open) - }> - - - - - - - - No config found. - - {Object.keys( - importedSettings.apiConfigs, - ).map((configName) => ( - - updateConfigSelection( - selection.id, - configName, - ) - }> - {configName} - {configName === - importedSettings.currentApiConfigName && ( - - (default) - - )} - - - ))} - - - - - - {index === configSelections.length - 1 ? ( - - ) : ( - - )} -
- ))} -
- )} - -
- -
- -
-
- - {settings && ( - - )} -
- ) : ( - <> -
- {modelSelections.map((selection, index) => ( -
- toggleModelPopover(selection.id, open)}> - - - - - - - - No model found. - - {models?.map(({ id, name }) => ( - - updateModelSelection( - selection.id, - id, - ) - }> - {name} - - - ))} - - - - - - {index === modelSelections.length - 1 ? ( - - ) : ( - - )} -
- ))} -
- -
- -
- -
-
- - )} - - -
- )} - /> - - {provider === "roo" && ( - ( - -
- Roo Code Cloud Token - - - - - -

- If you have access to the Roo Code Cloud repository and the - decryption key for the .env.* files, generate a token with: -

- - pnpm --filter @roo-code-cloud/auth production:create-auth-token - [email] [org] [ttl] - -
-
-
- - - - -
- )} - /> - )} - - ( - - Exercises -
- { - setValue("suite", value as "full" | "partial") - localStorage.setItem("evals-suite", value) - if (value === "full") { - setSelectedExercises([]) - setValue("exercises", []) - localStorage.removeItem("evals-exercises") - } - }}> - - All - Some - - - {suite === "partial" && languages.length > 0 && ( -
- {languages.map((lang) => ( - - ))} -
- )} -
- {suite === "partial" && ( - ({ value: path, label: path })) || []} - value={selectedExercises} - onValueChange={(value) => { - setSelectedExercises(value) - setValue("exercises", value) - localStorage.setItem("evals-exercises", JSON.stringify(value)) - }} - placeholder="Select" - variant="inverted" - maxCount={4} - /> - )} - -
- )} - /> - - {/* Concurrency, Timeout, and Iterations in a 3-column row */} -
- ( - - Concurrency - -
- { - field.onChange(value[0]) - localStorage.setItem("evals-concurrency", String(value[0])) - }} - /> -
{field.value}
-
-
- -
- )} - /> - - ( - - Timeout (Minutes) - -
- { - field.onChange(value[0]) - localStorage.setItem("evals-timeout", String(value[0])) - }} - /> -
{field.value}
-
-
- -
- )} - /> - - ( - - Iterations - -
- { - field.onChange(value[0]) - }} - /> -
{field.value}
-
-
- -
- )} - /> -
- - {/* Terminal timeouts in a 2-column row */} -
- -
- - - - - - -

- Maximum time in seconds to wait for terminal command execution to complete - before timing out. This applies to commands run via the execute_command - tool. -

-
-
-
-
- { - if (value !== undefined) { - setCommandExecutionTimeout(value) - localStorage.setItem("evals-command-execution-timeout", String(value)) - } - }} - /> -
{commandExecutionTimeout}
-
-
- - -
- - - - - - -

- Maximum time in seconds to wait for shell integration to initialize when - opening a new terminal. -

-
-
-
-
- { - if (value !== undefined) { - setTerminalShellIntegrationTimeout(value) - localStorage.setItem("evals-shell-integration-timeout", String(value)) - } - }} - /> -
{terminalShellIntegrationTimeout}
-
-
-
- - ( - - Description / Notes - -