diff --git a/.changeset/changelog-config.js b/.changeset/changelog-config.js index 0ab9a9e48e..00f93f281e 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/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index f8ac0c8642..1592b15669 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/.tool-versions b/.tool-versions index 269cea0b28..fc43bbb1c7 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ +pnpm 10.8.1 nodejs 20.19.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d932eae0d..f37ddcc424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,167 @@ # Roo Code Changelog +## 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) @@ -471,7 +633,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 @@ -1519,7 +1681,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 @@ -1829,7 +1991,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 @@ -1986,7 +2148,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) @@ -2104,7 +2266,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) @@ -2166,7 +2328,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) @@ -2572,7 +2734,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!) @@ -2677,7 +2839,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") diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index fee05d7225..328bb5c1b2 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -16,7 +16,7 @@ ## 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 +contributors and maintainers pledge to make 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 diff --git a/README.md b/README.md index 75f37762f9..e972366df7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ > Your AI-Powered Dev Team, Right in Your Editor +## What's New in v3.51.0 + +- Add support for OpenAI GPT-5.4 and GPT-5.3 Chat Latest so you can use the newest OpenAI chat models in Roo Code. +- Expose skills as slash commands with fallback execution to make reusable workflows faster to trigger. +
🌐 Available languages @@ -58,18 +63,17 @@ 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) +Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial & Feature Videos
-| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | -|
Custom Modes |
Checkpoints |
Context Management | +| | | | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | +|
Custom Modes |
Checkpoints |
Context Management |

diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index b59e4c7b95..45476e0e24 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,189 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi 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 diff --git a/apps/cli/README.md b/apps/cli/README.md index 62b03e5cd8..8dec1f3a1c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -41,6 +41,12 @@ Re-run the install script to update to the latest version: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh ``` +Or run: + +```bash +roo upgrade +``` + ### Uninstalling ```bash @@ -58,7 +64,7 @@ pnpm install # Build the main extension first. pnpm --filter roo-cline bundle -# Build the cli. +# Build the CLI. pnpm --filter @roo-code/cli build ``` @@ -107,15 +113,21 @@ 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 one prompt per line via stdin: +Send NDJSON commands via stdin: ```bash -printf '1+1=?\n10!=?\n' | roo --print --stdin-prompt-stream --output-format stream-json +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 ``` ### Roo Code Cloud Authentication @@ -164,24 +176,27 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo ## Options -| Option | Description | Default | -| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- | -| `[prompt]` | Your prompt (positional argument, optional) | None | -| `--prompt-file ` | Read prompt from a file instead of command line argument | None | -| `-w, --workspace ` | Workspace path to operate in | Current directory | -| `-p, --print` | Print response and exit (non-interactive mode) | `false` | -| `--stdin-prompt-stream` | Read prompts from stdin (one prompt per line, 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 (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | -| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | -| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | -| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | -| `--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` | +| 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 (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | +| `-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` | ## Auth Commands diff --git a/apps/cli/install.sh b/apps/cli/install.sh index 2576ec6cce..6830eb535b 100755 --- a/apps/cli/install.sh +++ b/apps/cli/install.sh @@ -104,12 +104,60 @@ get_version() { error "Failed to fetch releases from GitHub. Check your internet connection." } - # Extract the latest cli-v* tag - VERSION=$(echo "$RELEASES_JSON" | - grep -o '"tag_name": "cli-v[^"]*"' | - head -1 | - sed 's/"tag_name": "cli-v//' | - sed 's/"//') + # 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." diff --git a/apps/cli/package.json b/apps/cli/package.json index b00805b058..9276f17053 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.54", + "version": "0.1.17", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", @@ -13,6 +13,7 @@ "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": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts", diff --git a/apps/cli/scripts/build.sh b/apps/cli/scripts/build.sh index 97a33c384c..fae70473df 100755 --- a/apps/cli/scripts/build.sh +++ b/apps/cli/scripts/build.sh @@ -193,6 +193,7 @@ create_tarball() { import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; +import { existsSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -200,7 +201,10 @@ 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'); +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')); @@ -211,10 +215,21 @@ WRAPPER_EOF # 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" - tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" + 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" 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 0000000000..db942556b5 --- /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 0000000000..0596062f8f --- /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 0000000000..bb5f6f30c8 --- /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 0000000000..5647adaca9 --- /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 0000000000..cbefd26525 --- /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", + "roo", + "--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", + "roo", + "--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 0000000000..af8e0696bd --- /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 0000000000..55b1ccf94c --- /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 0000000000..8b2410f0d0 --- /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 0000000000..6f40c8d943 --- /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 0000000000..f5fee2626f --- /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 0000000000..5eb5a2f361 --- /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 0000000000..3166e78031 --- /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 0000000000..a45d1ed959 --- /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 0000000000..6bc0a369da --- /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 0000000000..b8fa9d3066 --- /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 0000000000..73b756c7c3 --- /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", "roo", "--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 0000000000..a39c8b14ae --- /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/agent/__tests__/events.test.ts b/apps/cli/src/agent/__tests__/events.test.ts new file mode 100644 index 0000000000..6d5802fa3f --- /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-host.test.ts b/apps/cli/src/agent/__tests__/extension-host.test.ts index 2354e3ab75..a0f68286e6 100644 --- a/apps/cli/src/agent/__tests__/extension-host.test.ts +++ b/apps/cli/src/agent/__tests__/extension-host.test.ts @@ -5,6 +5,8 @@ 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" @@ -80,13 +82,28 @@ function spyOnPrivate(host: ExtensionHost, method: string) { } 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 = { @@ -135,6 +152,28 @@ describe("ExtensionHost", () => { 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", () => { @@ -215,6 +254,26 @@ describe("ExtensionHost", () => { ) 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) + }) }) }) @@ -429,6 +488,26 @@ describe("ExtensionHost", () => { 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", () => { @@ -461,6 +540,37 @@ describe("ExtensionHost", () => { 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() @@ -484,6 +594,33 @@ describe("ExtensionHost", () => { 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", () => { @@ -494,6 +631,20 @@ describe("ExtensionHost", () => { 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 }) 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 0000000000..8d45538ce3 --- /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 0000000000..2be7adcbb5 --- /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 0000000000..6253fbdec6 --- /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/events.ts b/apps/cli/src/agent/events.ts index 9b374310ad..f455bf0c9d 100644 --- a/apps/cli/src/agent/events.ts +++ b/apps/cli/src/agent/events.ts @@ -260,7 +260,7 @@ export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo * Helper to determine if task completed. */ export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean { - const completionAsks = ["completion_result", "api_req_failed", "mistake_limit_reached"] + 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 diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index 4a0e941b4b..393990301f 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -26,7 +26,7 @@ import type { import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim" import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli" -import type { SupportedProvider } from "@/types/index.js" +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" @@ -66,6 +66,7 @@ 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 @@ -79,6 +80,7 @@ export interface ExtensionHostOptions { ephemeral: boolean debug: boolean exitOnComplete: boolean + terminalShell?: string /** * When true, exit the process on API request errors instead of retrying. */ @@ -107,7 +109,8 @@ interface WebviewViewProvider { export interface ExtensionHostInterface extends IExtensionHost { client: ExtensionClient activate(): Promise - runTask(prompt: string): Promise + runTask(prompt: string, taskId?: string, configuration?: RooCodeSettings, images?: string[]): Promise + resumeTask(taskId: string): Promise sendToExtension(message: WebviewMessage): void dispose(): Promise } @@ -135,6 +138,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Ephemeral storage. private ephemeralStorageDir: string | null = null + private previousCliRuntimeEnv: string | undefined // ========================================================================== // Managers - These do all the heavy lifting @@ -172,6 +176,10 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac 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) { @@ -213,8 +221,12 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Populate initial settings. const baseSettings: RooCodeSettings = { mode: this.options.mode, - commandExecutionTimeout: 30, + consecutiveMistakeLimit: this.options.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit, + commandExecutionTimeout: 300, enableCheckpoints: false, + experiments: { + customTools: true, + }, ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model), } @@ -246,6 +258,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac this.initialSettings.reasoningEffort = this.options.reasoningEffort } } + + if (this.options.terminalShell) { + this.initialSettings.terminalShellIntegrationDisabled = true + this.initialSettings.execaShellPath = this.options.terminalShell + } } // ========================================================================== @@ -458,9 +475,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // Task Management // ========================================================================== - public async runTask(prompt: string): Promise { - this.sendToExtension({ type: "newTask", text: prompt }) - + private waitForTaskCompletion(): Promise { return new Promise((resolve, reject) => { const completeHandler = () => { cleanup() @@ -501,6 +516,27 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac }) } + 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 // ========================================================================== @@ -567,5 +603,12 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac // 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/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts index 4a6d2629ae..7c60c384bb 100644 --- a/apps/cli/src/agent/json-event-emitter.ts +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -16,10 +16,11 @@ import type { ClineMessage } from "@roo-code/types" -import type { JsonEvent, JsonEventCost, JsonFinalOutput } from "@/types/json-events.js" +import type { JsonEvent, JsonEventCost, JsonEventQueueItem, JsonFinalOutput } from "@/types/json-events.js" import type { ExtensionClient } from "./extension-client.js" -import type { TaskCompletedEvent } from "./events.js" +import type { AgentStateChangeEvent, TaskCompletedEvent } from "./events.js" +import { AgentLoopState } from "./agent-state.js" /** * Options for JsonEventEmitter. @@ -29,6 +30,14 @@ export interface JsonEventEmitterOptions { 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[] } /** @@ -81,24 +90,55 @@ const SKIP_SAY_TYPES = new Set([ /** 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", + ] } /** @@ -108,19 +148,72 @@ export class JsonEventEmitter { // 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, unsubTaskCompleted, unsubError) + 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. */ @@ -146,6 +239,60 @@ export class JsonEventEmitter { 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. */ @@ -153,6 +300,138 @@ export class JsonEventEmitter { 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). */ @@ -160,6 +439,7 @@ export class JsonEventEmitter { if (this.mode === "stream-json" && isPartial) { return this.computeDelta(msgId, text) } + return text ?? null } @@ -174,15 +454,19 @@ export class JsonEventEmitter { 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 } @@ -205,21 +489,22 @@ export class JsonEventEmitter { if (isDone) { this.seenMessageIds.add(msg.ts) this.previousContent.delete(msg.ts) - } - - 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.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, contentToSend, isDone) + this.handleAskMessage(msg, isDone) } } @@ -236,6 +521,9 @@ export class JsonEventEmitter { } } else { this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone)) + if (msg.text) { + this.lastAssistantText = msg.text + } } break @@ -248,15 +536,15 @@ export class JsonEventEmitter { break case "command_output": - this.emitEvent({ - type: "tool_result", - tool_result: { name: "execute_command", output: msg.text }, - }) + 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": { @@ -314,40 +602,31 @@ export class JsonEventEmitter { /** * Handle "ask" type messages. */ - private handleAskMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void { + private handleAskMessage(msg: ClineMessage, isDone: boolean): void { switch (msg.ask) { - case "tool": { - const toolInfo = parseToolInfo(msg.text) - this.emitEvent({ - type: "tool_use", - id: msg.ts, - subtype: "tool", - tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } }, - }) + case "tool": + this.handleToolUseAsk(msg, "tool", isDone) break - } case "command": - this.emitEvent({ - type: "tool_use", - id: msg.ts, - subtype: "command", - tool_use: { name: "execute_command", input: { command: msg.text } }, - }) + this.handleToolUseAsk(msg, "command", isDone) break case "use_mcp_server": - this.emitEvent({ - type: "tool_use", - id: msg.ts, - subtype: "mcp", - tool_use: { name: "mcp_server", input: { raw: msg.text } }, - }) + this.handleToolUseAsk(msg, "mcp", isDone) break - case "followup": + 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 @@ -361,18 +640,147 @@ export class JsonEventEmitter { 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 { - // Use tracked completion result content, falling back to event message - const resultContent = this.completionResultContent || event.message?.text + // 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", @@ -383,13 +791,14 @@ export class JsonEventEmitter { 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) } - - // Next task in the same process starts with a new echoed prompt. - this.expectPromptEchoAsUser = true } /** @@ -409,10 +818,13 @@ export class JsonEventEmitter { * For json mode: accumulate for final output */ private emitEvent(event: JsonEvent): void { - this.events.push(event) + const requestId = event.requestId ?? this.requestIdProvider() + const payload = requestId ? { ...event, requestId } : event + + this.events.push(payload) if (this.mode === "stream-json") { - this.outputLine(event) + this.outputLine(payload) } } @@ -420,7 +832,7 @@ export class JsonEventEmitter { * Output a single JSON line (NDJSON format). */ private outputLine(data: unknown): void { - this.stdout.write(JSON.stringify(data) + "\n") + this.writeToStdout(JSON.stringify(data) + "\n") } /** @@ -435,7 +847,31 @@ export class JsonEventEmitter { events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself } - this.stdout.write(JSON.stringify(output, null, 2) + "\n") + 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]) + } } /** @@ -453,7 +889,17 @@ export class JsonEventEmitter { 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 index 2b9fd13602..f841932dcf 100644 --- a/apps/cli/src/agent/message-processor.ts +++ b/apps/cli/src/agent/message-processor.ts @@ -343,13 +343,16 @@ export class MessageProcessor { // 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: currentState.currentAsk === "completion_result", + success: completedSuccessfully, }) } const completedEvent: TaskCompletedEvent = { - success: currentState.currentAsk === "completion_result", + success: completedSuccessfully, stateInfo: currentState, message: currentState.lastMessage, } diff --git a/apps/cli/src/agent/output-manager.ts b/apps/cli/src/agent/output-manager.ts index 0863546f6c..805b090925 100644 --- a/apps/cli/src/agent/output-manager.ts +++ b/apps/cli/src/agent/output-manager.ts @@ -85,6 +85,12 @@ export class OutputManager { */ 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). */ @@ -197,6 +203,7 @@ export class OutputManager { this.displayedMessages.clear() this.streamedContent.clear() this.currentlyStreamingTs = null + this.completionResultStreamed = false this.loggedFirstPartial.clear() this.streamingState.next({ ts: null, isStreaming: false }) } @@ -248,8 +255,13 @@ export class OutputManager { this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete) break - // Note: completion_result is an "ask" type, not a "say" type. - // It is handled via the TaskCompleted event in extension-host.ts + 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) { @@ -401,13 +413,50 @@ export class OutputManager { } } + /** + * 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) { - this.output("\n[task complete]", text || "") + 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/commands/cli/__tests__/cancellation.test.ts b/apps/cli/src/commands/cli/__tests__/cancellation.test.ts new file mode 100644 index 0000000000..13cfa9aaea --- /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 0000000000..5058b8e8d8 --- /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 0000000000..3656ac6ce1 --- /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__/upgrade.test.ts b/apps/cli/src/commands/cli/__tests__/upgrade.test.ts new file mode 100644 index 0000000000..71fc39dd3e --- /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 0000000000..402fb93a4d --- /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 index 89e8e9f1ba..b59f1ebfa8 100644 --- a/apps/cli/src/commands/cli/index.ts +++ b/apps/cli/src/commands/cli/index.ts @@ -1 +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 0000000000..31898c59cd --- /dev/null +++ b/apps/cli/src/commands/cli/list.ts @@ -0,0 +1,324 @@ +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 { getProviderDefaultModelId } from "@roo-code/types" + +import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js" +import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js" +import { loadToken } from "@/lib/storage/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 || (await loadToken()) || getApiKeyFromEnv("roo") + + const extensionHostOptions: ExtensionHostOptions = { + mode: "code", + reasoningEffort: undefined, + user: null, + provider: "roo", + model: getProviderDefaultModelId("roo"), + 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 requestRooModels(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestRooModels", (message) => { + if (message.type !== "singleRouterModelFetchResponse") { + return undefined + } + + const values = isRecord(message.values) ? message.values : undefined + if (values?.provider !== "roo") { + return undefined + } + + if (message.success === false) { + const errorMessage = + typeof message.error === "string" && message.error.length > 0 + ? message.error + : "Failed to fetch Roo models" + throw new Error(errorMessage) + } + + return isRecord(values.models) ? (values.models 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 requestRooModels(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 index c7a01450a4..62760919e7 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -1,9 +1,9 @@ import fs from "fs" import path from "path" -import { createInterface } from "readline" import { fileURLToPath } from "url" import { createElement } from "react" +import pWaitFor from "p-wait-for" import { setLogger } from "@roo-code/vscode-shim" @@ -22,31 +22,89 @@ import { JsonEventEmitter } from "@/agent/json-event-emitter.js" import { createClient } from "@/lib/sdk/index.js" import { loadToken, loadSettings } from "@/lib/storage/index.js" +import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/lib/task-history/index.js" +import { isRecord } from "@/lib/utils/guards.js" import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" import { runOnboarding } from "@/lib/utils/onboarding.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 ROO_MODEL_WARMUP_TIMEOUT_MS = 10_000 +const SIGNAL_ONLY_EXIT_KEEPALIVE_MS = 60_000 +const STREAM_RESUME_WAIT_TIMEOUT_MS = 2_000 -async function* readPromptsFromStdinLines(): AsyncGenerator { - const lineReader = createInterface({ - input: process.stdin, - crlfDelay: Infinity, - terminal: false, - }) +async function bootstrapResumeForStdinStream(host: ExtensionHost, sessionId: string): Promise { + host.sendToExtension({ type: "showTaskWithId", text: sessionId }) - try { - for await (const line of lineReader) { - if (line.trim()) { - yield line - } + // 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)) +} + +async function warmRooModels(host: ExtensionHost): Promise { + await new Promise((resolve, reject) => { + let settled = false + + const cleanup = () => { + clearTimeout(timeoutId) + host.off("extensionWebviewMessage", onMessage) } - } finally { - lineReader.close() - } + + const finish = (fn: () => void) => { + if (settled) return + settled = true + cleanup() + fn() + } + + const onMessage = (message: unknown) => { + if (!isRecord(message)) { + return + } + + if (message.type !== "singleRouterModelFetchResponse") { + return + } + + const values = isRecord(message.values) ? message.values : undefined + + if (values?.provider !== "roo") { + return + } + + if (message.success === false) { + const errorMessage = + typeof message.error === "string" && message.error.length > 0 + ? message.error + : "failed to refresh Roo models" + + finish(() => reject(new Error(errorMessage))) + return + } + + finish(() => resolve()) + } + + const timeoutId = setTimeout(() => { + finish(() => reject(new Error(`timed out waiting for Roo models after ${ROO_MODEL_WARMUP_TIMEOUT_MS}ms`))) + }, ROO_MODEL_WARMUP_TIMEOUT_MS) + + host.on("extensionWebviewMessage", onMessage) + host.sendToExtension({ type: "requestRooModels" }) + }) } export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { @@ -68,6 +126,47 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption 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 let rooToken = await loadToken() @@ -89,10 +188,34 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption (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, @@ -103,6 +226,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption ephemeral: flagOptions.ephemeral, debug: flagOptions.debug, exitOnComplete: effectiveExitOnComplete, + terminalShell, } // Roo Code Cloud Authentication @@ -210,30 +334,64 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (flagOptions.stdinPromptStream && !flagOptions.print) { console.error("[CLI] Error: --stdin-prompt-stream requires --print mode") - console.error("[CLI] Usage: roo --print --stdin-prompt-stream [options]") + 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 '1+1=?\\n10!=?\\n' | roo --print --stdin-prompt-stream [options]") + 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 --stdin-prompt-stream [options]") + 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) { + 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 --stdin-prompt-stream [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]") @@ -259,6 +417,9 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption createElement(App, { ...extensionHostOptions, initialPrompt: prompt, + initialTaskId: requestedCreateSessionId, + initialSessionId: resolvedResumeSessionId, + continueSession: false, version: VERSION, createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts), }), @@ -276,68 +437,245 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } } 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" }) + ? 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...`) } - jsonEmitter?.detach() - await host.dispose() + + await disposeHost() + if (jsonEmitter) { + await jsonEmitter.flush() + } + await flushStdout() process.exit(exitCode) } - process.on("SIGINT", () => shutdown("SIGINT", 130)) - process.on("SIGTERM", () => shutdown("SIGTERM", 143)) + process.on("SIGINT", onSigint) + process.on("SIGTERM", onSigterm) + process.on("uncaughtException", onUncaughtException) + process.on("unhandledRejection", onUnhandledRejection) try { await host.activate() + if (extensionHostOptions.provider === "roo") { + try { + await warmRooModels(host) + } catch (warmupError) { + if (flagOptions.debug) { + const message = warmupError instanceof Error ? warmupError.message : String(warmupError) + console.error(`[CLI] Warning: Roo model warmup failed: ${message}`) + } + } + } if (jsonEmitter) { jsonEmitter.attachToClient(host.client) } if (useStdinPromptStream) { - let hasReceivedStdinPrompt = false - - for await (const stdinPrompt of readPromptsFromStdinLines()) { - hasReceivedStdinPrompt = true - await host.runTask(stdinPrompt) - jsonEmitter?.clear() + if (!jsonEmitter || outputFormat !== "stream-json") { + throw new Error("--stdin-prompt-stream requires --output-format=stream-json to emit control events") } - if (!hasReceivedStdinPrompt) { - throw new Error("no prompt provided via stdin") + if (isResumeRequested) { + await bootstrapResumeForStdinStream(host, resolvedResumeSessionId!) } + + await runStdinStreamMode({ + host, + jsonEmitter, + setStreamRequestId: (id) => { + streamRequestId = id + }, + }) } else { - await host.runTask(prompt!) + if (isResumeRequested) { + await host.resumeTask(resolvedResumeSessionId!) + } else { + await host.runTask(prompt!, requestedCreateSessionId) + } } - jsonEmitter?.detach() - await host.dispose() + 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) { - const errorMessage = error instanceof Error ? error.message : String(error) + emitRuntimeError(normalizeError(error)) + await disposeHost() + if (jsonEmitter) { + await jsonEmitter.flush() + } + await flushStdout() - if (useJsonOutput) { - const errorEvent = { type: "error", id: Date.now(), content: errorMessage } - process.stdout.write(JSON.stringify(errorEvent) + "\n") - } else { - console.error("[CLI] Error:", errorMessage) - if (error instanceof Error) { - console.error(error.stack) - } + if (signalOnlyExit) { + await parkUntilSignal("Task loop failed") } - jsonEmitter?.detach() - await host.dispose() + 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 0000000000..a9e4c47458 --- /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 0000000000..a3ff4ee94b --- /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/index.ts b/apps/cli/src/index.ts index 6eaab05987..2805e6c909 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -2,7 +2,17 @@ import { Command } from "commander" import { DEFAULT_FLAGS } from "@/types/constants.js" import { VERSION } from "@/lib/utils/version.js" -import { run, login, logout, status } from "@/commands/index.js" +import { + run, + login, + logout, + status, + listCommands, + listModes, + listModels, + listSessions, + upgrade, +} from "@/commands/index.js" const program = new Command() @@ -10,13 +20,27 @@ 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 prompts from stdin (one prompt per line, requires --print)", 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) @@ -24,11 +48,17 @@ program .option("--provider ", "API provider (roo, 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) @@ -39,6 +69,73 @@ program ) .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 ", "Roo API key (falls back to saved login/session token)") + .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 Roo 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()) + }) + const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud") authCommand diff --git a/apps/cli/src/lib/storage/__tests__/settings.test.ts b/apps/cli/src/lib/storage/__tests__/settings.test.ts index 30f1dbe8ec..f19b5c3a25 100644 --- a/apps/cli/src/lib/storage/__tests__/settings.test.ts +++ b/apps/cli/src/lib/storage/__tests__/settings.test.ts @@ -105,6 +105,7 @@ describe("Settings Storage", () => { provider: "anthropic" as const, model: "claude-opus-4.6", reasoningEffort: "medium" as const, + consecutiveMistakeLimit: 5, }) const savedData = await fs.readFile(expectedSettingsFile, "utf-8") @@ -114,6 +115,7 @@ describe("Settings Storage", () => { 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 () => { @@ -168,6 +170,7 @@ describe("Settings Storage", () => { provider: "openai-native" as const, model: "gpt-4o", reasoningEffort: "low" as const, + consecutiveMistakeLimit: 7, } await saveSettings(defaultSettings) @@ -177,6 +180,14 @@ describe("Settings Storage", () => { 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 () => { @@ -218,6 +229,7 @@ describe("Settings Storage", () => { provider: "anthropic" as const, model: "claude-sonnet-4-20250514", reasoningEffort: "high" as const, + consecutiveMistakeLimit: 9, requireApproval: true, oneshot: true, } @@ -229,6 +241,7 @@ describe("Settings Storage", () => { 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) }) 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 0000000000..58b0692b2b --- /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 0000000000..3be2d45d4c --- /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__/guards.test.ts b/apps/cli/src/lib/utils/__tests__/guards.test.ts new file mode 100644 index 0000000000..f59eeb506d --- /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__/shell.test.ts b/apps/cli/src/lib/utils/__tests__/shell.test.ts new file mode 100644 index 0000000000..7e94131c3b --- /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/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index df878e16b0..5cd58b55a8 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -46,6 +46,8 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.openAiModelId case "requesty": return config.requestyModelId + case "unbound": + return config.unboundModelId case "litellm": return config.litellmModelId case "vercel-ai-gateway": diff --git a/apps/cli/src/lib/utils/guards.ts b/apps/cli/src/lib/utils/guards.ts new file mode 100644 index 0000000000..a901f1a658 --- /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/session-id.ts b/apps/cli/src/lib/utils/session-id.ts new file mode 100644 index 0000000000..6bd5b06567 --- /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 0000000000..548df919b2 --- /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/types/constants.ts b/apps/cli/src/types/constants.ts index 6c54348a9c..b291b5f90e 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -4,6 +4,7 @@ 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"] diff --git a/apps/cli/src/types/json-events.ts b/apps/cli/src/types/json-events.ts index f18f3b2768..73eb1b7150 100644 --- a/apps/cli/src/types/json-events.ts +++ b/apps/cli/src/types/json-events.ts @@ -1,3 +1,15 @@ +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 * @@ -14,9 +26,9 @@ /** * Output format options for the CLI. */ -export const OUTPUT_FORMATS = ["text", "json", "stream-json"] as const +export const OUTPUT_FORMATS = rooCliOutputFormats -export type OutputFormat = (typeof OUTPUT_FORMATS)[number] +export type OutputFormat = RooCliOutputFormat export function isValidOutputFormat(format: string): format is OutputFormat { return (OUTPUT_FORMATS as readonly string[]).includes(format) @@ -25,53 +37,24 @@ export function isValidOutputFormat(format: string): format is OutputFormat { /** * Event type discriminators for JSON output. */ -export type JsonEventType = - | "system" // System messages (init, ready, shutdown) - | "assistant" // Assistant text messages - | "user" // User messages (echoed input) - | "tool_use" // Tool invocations (file ops, commands, browser, MCP) - | "tool_result" // Results from tool execution - | "thinking" // Reasoning/thinking content - | "error" // Errors - | "result" // Final task result +export type JsonEventType = RooCliEventType + +export type JsonEventQueueItem = RooCliQueueItem /** * Tool use information for tool_use events. */ -export interface JsonEventToolUse { - /** Tool name (e.g., "read_file", "write_to_file", "execute_command") */ - name: string - /** Tool input parameters */ - input?: Record -} +export type JsonEventToolUse = RooCliToolUse /** * Tool result information for tool_result events. */ -export interface JsonEventToolResult { - /** Tool name that produced this result */ - name: string - /** Tool output (for successful execution) */ - output?: string - /** Error message (for failed execution) */ - error?: string -} +export type JsonEventToolResult = RooCliToolResult /** * Cost and token usage information. */ -export interface JsonEventCost { - /** Total cost in USD */ - totalCost?: number - /** Input tokens used */ - inputTokens?: number - /** Output tokens generated */ - outputTokens?: number - /** Cache write tokens */ - cacheWrites?: number - /** Cache read tokens */ - cacheReads?: number -} +export type JsonEventCost = RooCliCost /** * Base JSON event structure. @@ -81,17 +64,35 @@ export interface JsonEventCost { * - Each delta includes `id` for easy correlation * - Final message has `done: true` */ -export interface JsonEvent { +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) */ @@ -106,7 +107,7 @@ export interface JsonEvent { * Final JSON output for "json" mode (single object at end). * Contains the result and accumulated messages. */ -export interface JsonFinalOutput { +export type JsonFinalOutput = RooCliFinalOutput & { /** Final result type */ type: "result" /** Whether the task succeeded */ diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index fbd132bfdc..ecd3922aa1 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -20,9 +20,13 @@ export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified" 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 @@ -31,7 +35,9 @@ export type FlagOptions = { provider?: SupportedProvider model?: string mode?: string + terminalShell?: string reasoningEffort?: ReasoningEffortFlagOptions + consecutiveMistakeLimit?: number ephemeral: boolean oneshot: boolean outputFormat?: OutputFormat @@ -58,6 +64,8 @@ export interface CliSettings { 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 */ diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index ee9bc41cee..ede7c83170 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -60,6 +60,9 @@ 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 @@ -71,6 +74,9 @@ export interface TUIAppProps extends ExtensionHostOptions { function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) { const { initialPrompt, + initialTaskId, + initialSessionId, + continueSession, workspacePath, extensionPath, user, @@ -170,6 +176,9 @@ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) const { sendToExtension, runTask, cleanup } = useExtensionHost({ initialPrompt, + initialTaskId, + initialSessionId, + continueSession, mode, reasoningEffort, user, diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts index 78074aab4f..235c7c5aa8 100644 --- a/apps/cli/src/ui/hooks/useExtensionHost.ts +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -1,15 +1,47 @@ import { useEffect, useRef, useCallback, useMemo } from "react" import { useApp } from "ink" import { randomUUID } from "crypto" -import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" +import pWaitFor from "p-wait-for" +import type { ExtensionMessage, HistoryItem, WebviewMessage } from "@roo-code/types" import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js" +import { arePathsEqual } from "@/lib/utils/path.js" import { useCLIStore } from "../store.js" +const TASK_HISTORY_WAIT_TIMEOUT_MS = 2_000 + +function extractTaskHistory(message: ExtensionMessage): HistoryItem[] | undefined { + if (message.type === "state" && Array.isArray(message.state?.taskHistory)) { + return message.state.taskHistory as HistoryItem[] + } + + if (message.type === "taskHistoryUpdated" && Array.isArray(message.taskHistory)) { + return message.taskHistory as HistoryItem[] + } + + return undefined +} + +function getMostRecentTaskId(taskHistory: HistoryItem[], workspacePath: string): string | undefined { + const workspaceTasks = taskHistory.filter( + (item) => typeof item.workspace === "string" && arePathsEqual(item.workspace, workspacePath), + ) + + if (workspaceTasks.length === 0) { + return undefined + } + + const sorted = [...workspaceTasks].sort((a, b) => b.ts - a.ts) + return sorted[0]?.id +} + // TODO: Unify with TUIAppProps? export interface UseExtensionHostOptions extends ExtensionHostOptions { initialPrompt?: string + initialTaskId?: string + initialSessionId?: string + continueSession?: boolean onExtensionMessage: (msg: ExtensionMessage) => void createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface } @@ -32,6 +64,9 @@ export interface UseExtensionHostReturn { */ export function useExtensionHost({ initialPrompt, + initialTaskId, + initialSessionId, + continueSession, mode, reasoningEffort, user, @@ -48,10 +83,12 @@ export function useExtensionHost({ createExtensionHost, }: UseExtensionHostOptions): UseExtensionHostReturn { const { exit } = useApp() - const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore() + const { addMessage, setComplete, setLoading, setHasStartedTask, setError, setCurrentTaskId, setIsResumingTask } = + useCLIStore() const hostRef = useRef(null) const isReadyRef = useRef(false) + const pendingInitialTaskIdRef = useRef(initialTaskId?.trim() || undefined) const cleanup = useCallback(async () => { if (hostRef.current) { @@ -64,6 +101,10 @@ export function useExtensionHost({ useEffect(() => { const init = async () => { try { + const requestedSessionId = initialSessionId?.trim() + let taskHistorySnapshot: HistoryItem[] = [] + let hasReceivedTaskHistory = false + const host = createExtensionHost({ mode, user, @@ -83,7 +124,17 @@ export function useExtensionHost({ hostRef.current = host isReadyRef.current = true - host.on("extensionWebviewMessage", (msg) => onExtensionMessage(msg as ExtensionMessage)) + host.on("extensionWebviewMessage", (msg) => { + const extensionMessage = msg as ExtensionMessage + const taskHistory = extractTaskHistory(extensionMessage) + + if (taskHistory) { + taskHistorySnapshot = taskHistory + hasReceivedTaskHistory = true + } + + onExtensionMessage(extensionMessage) + }) host.client.on("taskCompleted", async () => { setComplete(true) @@ -108,13 +159,46 @@ export function useExtensionHost({ host.sendToExtension({ type: "requestCommands" }) host.sendToExtension({ type: "requestModes" }) + if (requestedSessionId || continueSession) { + await pWaitFor(() => hasReceivedTaskHistory, { + interval: 25, + timeout: TASK_HISTORY_WAIT_TIMEOUT_MS, + }).catch(() => undefined) + + if (requestedSessionId && hasReceivedTaskHistory) { + const hasRequestedTask = taskHistorySnapshot.some((item) => item.id === requestedSessionId) + + if (!hasRequestedTask) { + throw new Error(`Session not found in task history: ${requestedSessionId}`) + } + } + + const resolvedSessionId = + requestedSessionId || getMostRecentTaskId(taskHistorySnapshot, workspacePath) + + if (continueSession && !resolvedSessionId) { + throw new Error("No previous tasks found to continue in this workspace.") + } + + if (resolvedSessionId) { + setCurrentTaskId(resolvedSessionId) + setIsResumingTask(true) + setHasStartedTask(true) + setLoading(true) + host.sendToExtension({ type: "showTaskWithId", text: resolvedSessionId }) + return + } + } + setLoading(false) if (initialPrompt) { setHasStartedTask(true) setLoading(true) addMessage({ id: randomUUID(), role: "user", content: initialPrompt }) - await host.runTask(initialPrompt) + const taskId = pendingInitialTaskIdRef.current + pendingInitialTaskIdRef.current = undefined + await host.runTask(initialPrompt, taskId) } } catch (err) { setError(err instanceof Error ? err.message : String(err)) @@ -142,7 +226,9 @@ export function useExtensionHost({ return Promise.reject(new Error("Extension host not ready")) } - return hostRef.current.runTask(prompt) + const taskId = pendingInitialTaskIdRef.current + pendingInitialTaskIdRef.current = undefined + return hostRef.current.runTask(prompt, taskId) }, []) // Memoized return object to prevent unnecessary re-renders in consumers. diff --git a/apps/web-roo-code/src/app/linear/page.tsx b/apps/web-roo-code/src/app/linear/page.tsx new file mode 100644 index 0000000000..40334e2698 --- /dev/null +++ b/apps/web-roo-code/src/app/linear/page.tsx @@ -0,0 +1,413 @@ +import { + ArrowRight, + CheckCircle, + CreditCard, + Eye, + GitBranch, + GitPullRequest, + Link2, + MessageSquare, + Settings, + Shield, +} from "lucide-react" +import type { LucideIcon } from "lucide-react" +import type { Metadata } from "next" + +import { AnimatedBackground } from "@/components/homepage" +import { LinearIssueDemo } from "@/components/linear/linear-issue-demo" +import { Button } from "@/components/ui" +import { EXTERNAL_LINKS } from "@/lib/constants" +import { SEO } from "@/lib/seo" +import { ogImageUrl } from "@/lib/og" + +const TITLE = "Roo Code for Linear" +const DESCRIPTION = "Assign development work to @Roo Code directly from Linear. Get PRs back without switching tools." +const OG_DESCRIPTION = "Turn Linear Issues into Pull Requests" +const PATH = "/linear" + +// Featured Workflow section is temporarily commented out until video is ready +// const LINEAR_DEMO_YOUTUBE_ID = "" + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: `${SEO.url}${PATH}`, + }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: `${SEO.url}${PATH}`, + siteName: SEO.name, + images: [ + { + url: ogImageUrl(TITLE, OG_DESCRIPTION), + width: 1200, + height: 630, + alt: TITLE, + }, + ], + locale: SEO.locale, + type: "website", + }, + twitter: { + card: SEO.twitterCard, + title: TITLE, + description: DESCRIPTION, + images: [ogImageUrl(TITLE, OG_DESCRIPTION)], + }, + keywords: [ + ...SEO.keywords, + "linear integration", + "issue to PR", + "AI in Linear", + "engineering workflow automation", + "Roo Code Cloud", + ], +} + +// Invalidate cache when a request comes in, at most once every hour. +export const revalidate = 3600 + +type ValueProp = { + icon: LucideIcon + title: string + description: string +} + +const VALUE_PROPS: ValueProp[] = [ + { + icon: GitBranch, + title: "Work where you already work.", + description: + "Assign development work to @Roo Code directly from Linear. No new tools to learn, no context switching required.", + }, + { + icon: Eye, + title: "Progress is visible.", + description: + "Watch progress unfold in real-time. Roo Code posts updates as comments, so your whole team stays in the loop.", + }, + { + icon: MessageSquare, + title: "Mention for refinement.", + description: + 'Need changes? Just comment "@Roo Code also add dark mode support" and the agent picks up where it left off.', + }, + { + icon: Link2, + title: "Full traceability.", + description: + "Every PR links back to the originating issue. Every issue shows its linked PR. Your audit trail stays clean.", + }, + { + icon: Settings, + title: "Organization-level setup.", + description: + "Connect once, use everywhere. Your team members can assign issues to @Roo Code without individual configuration.", + }, + { + icon: Shield, + title: "Safe by design.", + description: + "Agents never touch main/master directly. They produce branches and PRs. You review and approve before merge.", + }, +] + +// type WorkflowStep = { +// step: number +// title: string +// description: string +// } + +// const WORKFLOW_STEPS: WorkflowStep[] = [ +// { +// step: 1, +// title: "Create an issue", +// description: "Write your issue with acceptance criteria. Be as detailed as you like.", +// }, +// { +// step: 2, +// title: "Call @Roo Code", +// description: "Mention @Roo Code in a comment to start. The agent begins working immediately.", +// }, +// { +// step: 3, +// title: "Watch progress", +// description: "Roo Code posts status updates as comments. Refine with @-mentions if needed.", +// }, +// { +// step: 4, +// title: "Review the PR", +// description: "When ready, the PR link appears in the issue. Review, iterate, and ship.", +// }, +// ] + +type OnboardingStep = { + icon: LucideIcon + title: string + description: string + link?: { + href: string + text: string + } +} + +const ONBOARDING_STEPS: OnboardingStep[] = [ + { + icon: CreditCard, + title: "1. Team Plan", + description: "Linear integration requires a Team plan.", + link: { + href: EXTERNAL_LINKS.CLOUD_APP_TEAM_TRIAL, + text: "Start a free trial", + }, + }, + { + icon: GitPullRequest, + title: "2. Connect GitHub", + description: "Link your repositories so Roo Code can open PRs on your behalf.", + }, + { + icon: Settings, + title: "3. Connect Linear", + description: "Authorize via OAuth. No API keys to manage or rotate.", + }, + { + icon: CheckCircle, + title: "4. Link & Start", + description: "Map your Linear project to a repo, then assign or mention @Roo Code.", + }, +] + +function LinearIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +export default function LinearPage(): JSX.Element { + return ( + <> + {/* Hero Section */} +

+ +
+
+
+
+ + Powered by Roo Code Cloud +
+

+ Turn Linear Issues into Pull Requests +

+

+ Assign development work to @Roo Code directly from Linear. Get PRs back without + switching tools. +

+ +
+ +
+ +
+
+
+
+ + {/* Value Props Section */} +
+
+
+
+
+
+

+ Why your team will love using Roo Code in Linear +

+

+ AI agents that understand context, keep your team in the loop, and deliver PRs you can + review. +

+
+
+ {VALUE_PROPS.map((prop, index) => { + const Icon = prop.icon + return ( +
+
+ +
+

{prop.title}

+

{prop.description}

+
+ ) + })} +
+
+
+ + {/* Featured Workflow Section - temporarily commented out until video is ready +
+
+
+
+
+ +
+
+ + Featured Workflow +
+

Issue to Shipped Feature

+

+ Stay in Linear from assignment to review. Roo Code keeps the issue updated and links the PR + when it's ready. +

+
+ +
+
+ {/* YouTube Video Embed or Placeholder */} + {/*
+ {LINEAR_DEMO_YOUTUBE_ID ? ( +