mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-12 23:01:21 +00:00
Merge branch 'RooCodeInc:main' into mybranch
This commit is contained in:
commit
8211e70b8d
367 changed files with 17834 additions and 5642 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
63
.github/workflows/code-qa.yml
vendored
63
.github/workflows/code-qa.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
pnpm 10.8.1
|
||||
nodejs 20.19.2
|
||||
|
|
|
|||
178
CHANGELOG.md
178
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
|
||||
|
||||

|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
README.md
16
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.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Available languages</summary>
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | | |
|
||||
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Installing Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configuring Profiles</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Codebase Indexing</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Custom Modes</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Context Management</b> |
|
||||
| | | |
|
||||
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Installing Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configuring Profiles</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Codebase Indexing</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Custom Modes</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Context Management</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
|
|
@ -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 <taskId>` 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
|
||||
|
|
|
|||
|
|
@ -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 <path>` | Read prompt from a file instead of command line argument | None |
|
||||
| `-w, --workspace <path>` | 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>` | 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 <key>` | API key for the LLM provider | From env var |
|
||||
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
|
||||
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
|
||||
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
|
||||
| `-r, --reasoning-effort <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 <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
|
||||
| Option | Description | Default |
|
||||
| --------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `[prompt]` | Your prompt (positional argument, optional) | None |
|
||||
| `--prompt-file <path>` | Read prompt from a file instead of command line argument | None |
|
||||
| `--create-with-session-id <session-id>` | Create a new task using the provided session ID (UUID) | None |
|
||||
| `-w, --workspace <path>` | 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>` | 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 <key>` | API key for the LLM provider | From env var |
|
||||
| `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
|
||||
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
|
||||
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
|
||||
| `--terminal-shell <path>` | Absolute shell path for inline terminal command execution | Auto-detected shell |
|
||||
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
|
||||
| `--consecutive-mistake-limit <n>` | 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 <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
|
||||
|
||||
## Auth Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
104
apps/cli/scripts/integration/cases/cancel-active-task.ts
Normal file
104
apps/cli/scripts/integration/cases/cancel-active-task.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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("<user_message>")
|
||||
) {
|
||||
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 (<user_message>) 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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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<TaskSessionEntry[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
135
apps/cli/scripts/integration/cases/followup-after-completion.ts
Normal file
135
apps/cli/scripts/integration/cases/followup-after-completion.ts
Normal file
|
|
@ -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("<user_message>")
|
||||
) {
|
||||
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 (<user_message>), 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)
|
||||
})
|
||||
|
|
@ -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 (<user_message>)")
|
||||
}
|
||||
|
||||
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("<user_message>")
|
||||
) {
|
||||
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)
|
||||
})
|
||||
|
|
@ -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("<user_message>")
|
||||
) {
|
||||
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 (<user_message>), 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)
|
||||
})
|
||||
159
apps/cli/scripts/integration/cases/followup-during-streaming.ts
Normal file
159
apps/cli/scripts/integration/cases/followup-during-streaming.ts
Normal file
|
|
@ -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("<user_message>")
|
||||
) {
|
||||
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 (<user_message>), 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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
148
apps/cli/scripts/integration/cases/mixed-command-ordering.ts
Normal file
148
apps/cli/scripts/integration/cases/mixed-command-ordering.ts
Normal file
|
|
@ -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<string, string[]>()
|
||||
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)
|
||||
})
|
||||
184
apps/cli/scripts/integration/cases/multi-message-queue-order.ts
Normal file
184
apps/cli/scripts/integration/cases/multi-message-queue-order.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
76
apps/cli/scripts/integration/cases/shutdown-while-running.ts
Normal file
76
apps/cli/scripts/integration/cases/shutdown-while-running.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
77
apps/cli/scripts/integration/cases/start-while-busy.ts
Normal file
77
apps/cli/scripts/integration/cases/start-while-busy.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
152
apps/cli/scripts/integration/lib/stream-harness.ts
Normal file
152
apps/cli/scripts/integration/lib/stream-harness.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
}
|
||||
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<void> {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
111
apps/cli/scripts/integration/run.ts
Normal file
111
apps/cli/scripts/integration/run.ts
Normal file
|
|
@ -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<string[]> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
35
apps/cli/src/agent/__tests__/events.test.ts
Normal file
35
apps/cli/src/agent/__tests__/events.test.ts
Normal file
|
|
@ -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>): 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, unknown>).vscode
|
||||
delete (global as Record<string, unknown>).__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<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.consecutiveMistakeLimit).toBe(DEFAULT_FLAGS.consecutiveMistakeLimit)
|
||||
})
|
||||
|
||||
it("should set consecutiveMistakeLimit from options", () => {
|
||||
const host = createTestHost({ consecutiveMistakeLimit: 8 })
|
||||
|
||||
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
|
||||
expect(initialSettings.consecutiveMistakeLimit).toBe(8)
|
||||
})
|
||||
|
||||
it("should enable auto-approval in non-interactive mode", () => {
|
||||
const host = createTestHost({ nonInteractive: true })
|
||||
|
||||
|
|
|
|||
170
apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts
Normal file
170
apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { Writable } from "stream"
|
||||
|
||||
import { JsonEventEmitter } from "../json-event-emitter.js"
|
||||
|
||||
function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record<string, unknown>[] } {
|
||||
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<string, unknown>)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
129
apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts
Normal file
129
apps/cli/src/agent/__tests__/json-event-emitter-result.test.ts
Normal file
|
|
@ -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<string, unknown>[] } {
|
||||
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<string, unknown>)
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, unknown>[] } {
|
||||
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<string, unknown>)
|
||||
|
||||
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>): 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ExtensionHostEventMap> {
|
||||
client: ExtensionClient
|
||||
activate(): Promise<void>
|
||||
runTask(prompt: string): Promise<void>
|
||||
runTask(prompt: string, taskId?: string, configuration?: RooCodeSettings, images?: string[]): Promise<void>
|
||||
resumeTask(taskId: string): Promise<void>
|
||||
sendToExtension(message: WebviewMessage): void
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
this.sendToExtension({ type: "newTask", text: prompt })
|
||||
|
||||
private waitForTaskCompletion(): Promise<void> {
|
||||
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<void> {
|
||||
this.sendToExtension({
|
||||
type: "newTask",
|
||||
text: prompt,
|
||||
taskId,
|
||||
taskConfiguration: configuration,
|
||||
...(images !== undefined ? { images } : {}),
|
||||
})
|
||||
return this.waitForTaskCompletion()
|
||||
}
|
||||
|
||||
public async resumeTask(taskId: string): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Promise<void>>()
|
||||
private lastCost: JsonEventCost | undefined
|
||||
private requestIdProvider: () => string | undefined
|
||||
private schemaVersion: number
|
||||
private protocol: string
|
||||
private capabilities: string[]
|
||||
private seenMessageIds = new Set<number>()
|
||||
// Track previous content for delta computation
|
||||
private previousContent = new Map<number, string>()
|
||||
// Track previous tool-use content for structured (non-append-only) delta computation.
|
||||
private previousToolUseContent = new Map<number, string>()
|
||||
// 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<number, string>()
|
||||
// Track command ids whose output is being streamed from commandExecutionStatus updates.
|
||||
private statusDrivenCommandOutputIds = new Set<number>()
|
||||
// Track command ids that already emitted a terminal command_output done event.
|
||||
private completedCommandOutputIds = new Set<number>()
|
||||
// Track exited commands awaiting final say:command_output completion.
|
||||
private pendingCommandCompletionByToolUseId = new Map<number, { exitCode?: number; timer: NodeJS.Timeout }>()
|
||||
// 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<void>((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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
apps/cli/src/commands/cli/__tests__/cancellation.test.ts
Normal file
104
apps/cli/src/commands/cli/__tests__/cancellation.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
84
apps/cli/src/commands/cli/__tests__/list.test.ts
Normal file
84
apps/cli/src/commands/cli/__tests__/list.test.ts
Normal file
|
|
@ -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<typeof import("@/lib/task-history/index.js")>()
|
||||
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<void>): Promise<string> => {
|
||||
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)"])
|
||||
})
|
||||
})
|
||||
247
apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts
Normal file
247
apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
93
apps/cli/src/commands/cli/__tests__/upgrade.test.ts
Normal file
93
apps/cli/src/commands/cli/__tests__/upgrade.test.ts
Normal file
|
|
@ -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<typeof vi.spyOn>
|
||||
|
||||
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.")
|
||||
})
|
||||
})
|
||||
131
apps/cli/src/commands/cli/cancellation.ts
Normal file
131
apps/cli/src/commands/cli/cancellation.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
export * from "./run.js"
|
||||
export * from "./list.js"
|
||||
export * from "./upgrade.js"
|
||||
|
|
|
|||
324
apps/cli/src/commands/cli/list.ts
Normal file
324
apps/cli/src/commands/cli/list.ts
Normal file
|
|
@ -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<Command, "name" | "source" | "filePath" | "description" | "argumentHint">
|
||||
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<ExtensionHost> {
|
||||
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<T>(
|
||||
host: ExtensionHost,
|
||||
requestType: WebviewMessage["type"],
|
||||
extract: (message: Record<string, unknown>) => T | undefined,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((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<CommandLike[]> {
|
||||
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<ModeLike[]> {
|
||||
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<ModelRecord> {
|
||||
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<T>(
|
||||
options: BaseListOptions,
|
||||
hostOptions: ListHostOptions,
|
||||
fn: (host: ExtensionHost) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<string> {
|
||||
const lineReader = createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
terminal: false,
|
||||
})
|
||||
async function bootstrapResumeForStdinStream(host: ExtensionHost, sessionId: string): Promise<void> {
|
||||
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<void> {
|
||||
await new Promise<void>((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 <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] <prompt>")
|
||||
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 <prompt> [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<void>((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<never> => {
|
||||
ensureKeepAliveInterval()
|
||||
|
||||
if (!useJsonOutput) {
|
||||
console.error(`[CLI] ${reason} (--signal-only-exit active; waiting for SIGINT/SIGTERM).`)
|
||||
}
|
||||
|
||||
await new Promise<void>(() => {})
|
||||
throw new Error("unreachable")
|
||||
}
|
||||
|
||||
async function shutdown(signal: string, exitCode: number): Promise<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
977
apps/cli/src/commands/cli/stdin-stream.ts
Normal file
977
apps/cli/src/commands/cli/stdin-stream.ts
Normal file
|
|
@ -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<StdinStreamCommandName>(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<StdinStreamCommand> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> | 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<string, string>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
155
apps/cli/src/commands/cli/upgrade.ts
Normal file
155
apps/cli/src/commands/cli/upgrade.ts
Normal file
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.")
|
||||
}
|
||||
|
|
@ -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 <path>", "Read prompt from a file instead of command line argument")
|
||||
.option("--create-with-session-id <session-id>", "Create a new task with a specific session ID (must be a UUID)")
|
||||
.option("--session-id <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 <path>", "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>", "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 <provider>", "API provider (roo, anthropic, openai, openrouter, etc.)")
|
||||
.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
|
||||
.option("--mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
|
||||
.option("--terminal-shell <path>", "Absolute path to shell executable for inline terminal commands")
|
||||
.option(
|
||||
"-r, --reasoning-effort <effort>",
|
||||
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
|
||||
DEFAULT_FLAGS.reasoningEffort,
|
||||
)
|
||||
.option(
|
||||
"--consecutive-mistake-limit <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 <path>", "Workspace directory path (defaults to current working directory)")
|
||||
.option("-e, --extension <path>", "Path to the extension bundle directory")
|
||||
.option("-k, --api-key <key>", "Roo API key (falls back to saved login/session token)")
|
||||
.option("--format <format>", 'Output format: "json" (default) or "text"', "json")
|
||||
.option("-d, --debug", "Enable debug output", false)
|
||||
|
||||
const runListAction = async (action: () => Promise<void>) => {
|
||||
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<void>) => {
|
||||
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<typeof listCommands>[0]) => {
|
||||
await runListAction(() => listCommands(options))
|
||||
},
|
||||
)
|
||||
|
||||
applyListOptions(listCommand.command("modes").description("List available modes")).action(
|
||||
async (options: Parameters<typeof listModes>[0]) => {
|
||||
await runListAction(() => listModes(options))
|
||||
},
|
||||
)
|
||||
|
||||
applyListOptions(listCommand.command("models").description("List available Roo models")).action(
|
||||
async (options: Parameters<typeof listModels>[0]) => {
|
||||
await runListAction(() => listModels(options))
|
||||
},
|
||||
)
|
||||
|
||||
applyListOptions(listCommand.command("sessions").description("List task sessions")).action(
|
||||
async (options: Parameters<typeof listSessions>[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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
75
apps/cli/src/lib/task-history/__tests__/index.test.ts
Normal file
75
apps/cli/src/lib/task-history/__tests__/index.test.ts
Normal file
|
|
@ -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<typeof import("@roo-code/core/cli")>()
|
||||
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")
|
||||
})
|
||||
})
|
||||
44
apps/cli/src/lib/task-history/index.ts
Normal file
44
apps/cli/src/lib/task-history/index.ts
Normal file
|
|
@ -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<TaskSessionEntry[]> {
|
||||
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
|
||||
}
|
||||
27
apps/cli/src/lib/utils/__tests__/guards.test.ts
Normal file
27
apps/cli/src/lib/utils/__tests__/guards.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
54
apps/cli/src/lib/utils/__tests__/shell.test.ts
Normal file
54
apps/cli/src/lib/utils/__tests__/shell.test.ts
Normal file
|
|
@ -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<ReturnType<typeof fs.stat>>)
|
||||
})
|
||||
|
||||
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<ReturnType<typeof fs.stat>>)
|
||||
const result = await validateTerminalShellPath("/bin")
|
||||
|
||||
expect(result).toEqual({ valid: false, reason: "shell path must point to a file" })
|
||||
})
|
||||
})
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
3
apps/cli/src/lib/utils/guards.ts
Normal file
3
apps/cli/src/lib/utils/guards.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
5
apps/cli/src/lib/utils/session-id.ts
Normal file
5
apps/cli/src/lib/utils/session-id.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
47
apps/cli/src/lib/utils/shell.ts
Normal file
47
apps/cli/src/lib/utils/shell.ts
Normal file
|
|
@ -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<TerminalShellValidationResult> {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
}
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ExtensionHostInterface | null>(null)
|
||||
const isReadyRef = useRef(false)
|
||||
const pendingInitialTaskIdRef = useRef<string | undefined>(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.
|
||||
|
|
|
|||
413
apps/web-roo-code/src/app/linear/page.tsx
Normal file
413
apps/web-roo-code/src/app/linear/page.tsx
Normal file
|
|
@ -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 (
|
||||
<svg className={className} viewBox="0 0 100 100" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LinearPage(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<section className="relative flex pt-32 pb-20 items-center overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div className="container relative flex flex-col items-center h-full z-10 mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid w-full max-w-6xl grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-12">
|
||||
<div className="text-center lg:text-left">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 text-sm font-medium mb-6">
|
||||
<LinearIcon className="size-4" />
|
||||
Powered by Roo Code Cloud
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-6 md:text-5xl lg:text-6xl">
|
||||
Turn Linear Issues into <span className="text-indigo-500">Pull Requests</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto lg:mx-0">
|
||||
Assign development work to @Roo Code directly from Linear. Get PRs back without
|
||||
switching tools.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
|
||||
<Button
|
||||
size="xl"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white transition-all duration-300 shadow-lg hover:shadow-indigo-500/25"
|
||||
asChild>
|
||||
<a
|
||||
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP_HOME}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center">
|
||||
Get Started
|
||||
<ArrowRight className="ml-2 size-5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center lg:justify-end">
|
||||
<LinearIssueDemo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Value Props Section */}
|
||||
<section className="py-24 bg-muted/30">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative">
|
||||
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
|
||||
<div className="absolute left-1/2 top-1/2 h-[800px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-indigo-500/10 dark:bg-indigo-700/20 blur-[140px]" />
|
||||
</div>
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
Why your team will love using Roo Code in Linear
|
||||
</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
AI agents that understand context, keep your team in the loop, and deliver PRs you can
|
||||
review.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto relative">
|
||||
{VALUE_PROPS.map((prop, index) => {
|
||||
const Icon = prop.icon
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-background p-8 rounded-2xl border border-border hover:shadow-lg transition-all duration-300">
|
||||
<div className="bg-indigo-100 dark:bg-indigo-900/20 w-12 h-12 rounded-lg flex items-center justify-center mb-6">
|
||||
<Icon className="size-6 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-3">{prop.title}</h3>
|
||||
<p className="text-muted-foreground leading-relaxed">{prop.description}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Workflow Section - temporarily commented out until video is ready
|
||||
<section id="demo" className="relative overflow-hidden border-t border-border py-24 lg:py-32">
|
||||
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
|
||||
<div className="absolute left-1/2 top-1/2 h-[400px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/10 dark:bg-blue-700/20 blur-[140px]" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-12 max-w-5xl text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-sm font-medium mb-6">
|
||||
<Zap className="size-4" />
|
||||
Featured Workflow
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-5xl mb-4">Issue to Shipped Feature</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Stay in Linear from assignment to review. Roo Code keeps the issue updated and links the PR
|
||||
when it's ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10 items-center">
|
||||
{/* YouTube Video Embed or Placeholder */}
|
||||
{/*<div className="lg:col-span-3 overflow-hidden rounded-2xl border border-border bg-background shadow-lg">
|
||||
{LINEAR_DEMO_YOUTUBE_ID ? (
|
||||
<iframe
|
||||
className="aspect-video w-full"
|
||||
src={`https://www.youtube-nocookie.com/embed/${LINEAR_DEMO_YOUTUBE_ID}?rel=0`}
|
||||
title="Roo Code Linear Integration Demo"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video w-full flex flex-col items-center justify-center bg-gradient-to-br from-indigo-500/10 via-blue-500/5 to-purple-500/10 text-center p-8">
|
||||
<LinearIcon className="size-16 text-indigo-500/50 mb-4" />
|
||||
<p className="text-lg font-semibold text-foreground mb-2">
|
||||
Demo Video Coming Soon
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
See the workflow in action: assign an issue to @Roo Code and watch as it
|
||||
analyzes requirements, writes code, and opens a PR.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workflow Steps */}
|
||||
{/*<div className="lg:col-span-2 space-y-3">
|
||||
{WORKFLOW_STEPS.map((step) => (
|
||||
<div
|
||||
key={step.step}
|
||||
className="relative border border-border rounded-xl bg-background p-4 transition-all duration-300 hover:shadow-md hover:border-blue-500/30">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-blue-100 dark:bg-blue-900/30 w-7 h-7 rounded-full flex items-center justify-center text-blue-700 dark:text-blue-300 font-bold text-xs shrink-0 mt-0.5">
|
||||
{step.step}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-foreground mb-0.5">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-snug text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
*/}
|
||||
|
||||
{/* Onboarding Section */}
|
||||
<section className="py-24 bg-muted/30">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">Get started in minutes</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Connect Linear and start assigning issues to AI.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 max-w-5xl mx-auto">
|
||||
{ONBOARDING_STEPS.map((step, index) => {
|
||||
const Icon = step.icon
|
||||
return (
|
||||
<div key={index} className="text-center">
|
||||
<div className="bg-indigo-100 dark:bg-indigo-900/20 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Icon className="size-8 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{step.title}</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{step.description}
|
||||
{step.link && (
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
href={step.link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||
{step.link.text} →
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-24">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl rounded-3xl border border-border/50 bg-gradient-to-br from-indigo-500/10 via-purple-500/5 to-blue-500/5 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-white/10 sm:p-16">
|
||||
<h2 className="mb-6 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Start using Roo Code in Linear
|
||||
</h2>
|
||||
<p className="mx-auto mb-10 max-w-2xl text-lg text-muted-foreground">
|
||||
Start a free 14 day Team trial.
|
||||
</p>
|
||||
<div className="flex flex-col justify-center space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-foreground text-background hover:bg-foreground/90 transition-all duration-300"
|
||||
asChild>
|
||||
<a
|
||||
href={EXTERNAL_LINKS.CLOUD_APP_TEAM_TRIAL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center">
|
||||
Start free trial
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ export default async function Home() {
|
|||
<p>
|
||||
Use the <strong className="text-nowrap">Roo Code Extension</strong> on your computer for
|
||||
full control, or delegate work to your{" "}
|
||||
<strong className="text-nowrap">Roo Code Cloud Agents</strong> from the web, Slack, Github
|
||||
<strong className="text-nowrap">Roo Code Cloud Agents</strong> from the web, Slack, GitHub
|
||||
or wherever your team is.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ const pricingTiers: PricingTier[] = [
|
|||
description: "For AI-forward engineers",
|
||||
featuresIntro: "Go beyond the extension with",
|
||||
features: [
|
||||
"Access to Cloud Agents: fully autonomous development you can kick off from Github and the web",
|
||||
"Access to Cloud Agents: fully autonomous development you can kick off from GitHub and the web",
|
||||
"Access to the Roo Code Router",
|
||||
"Follow your tasks from anywhere",
|
||||
"Share tasks with friends and co-workers",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { motion } from "framer-motion"
|
||||
import Image from "next/image"
|
||||
|
||||
const logos = ["Apple", "Netflix", "Microsoft", "Amazon", "ByteDance", "Rakuten", "Carvana"]
|
||||
const logos = ["Apple", "Microsoft", "Amazon", "ByteDance", "Rakuten", "Carvana"]
|
||||
|
||||
export function CompanyLogos() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function OptionOverviewSection() {
|
|||
|
||||
<div className="text-muted-foreground mb-4">
|
||||
<p>
|
||||
Create your agent team in the Cloud, give them access to Github and start giving them
|
||||
Create your agent team in the Cloud, give them access to GitHub and start giving them
|
||||
tasks:
|
||||
</p>
|
||||
<ul className="list-inside my-4 space-y-1">
|
||||
|
|
@ -76,7 +76,7 @@ export function OptionOverviewSection() {
|
|||
<li className="list-disc">
|
||||
Create tasks from the Web and Slack (more integrations soon)
|
||||
</li>
|
||||
<li className="list-disc">Get PR Reviews (and fixes) directly on Github</li>
|
||||
<li className="list-disc">Get PR Reviews (and fixes) directly on GitHub</li>
|
||||
<li className="list-disc">Collaborate with co-workers</li>
|
||||
</ul>
|
||||
<p>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const SOURCES = {
|
|||
icon: Pointer,
|
||||
},
|
||||
github: {
|
||||
name: "Github",
|
||||
name: "GitHub",
|
||||
icon: Github,
|
||||
},
|
||||
extension: {
|
||||
|
|
@ -161,7 +161,7 @@ const USE_CASES: UseCase[] = [
|
|||
},
|
||||
{
|
||||
role: "Support Engineer",
|
||||
use: "What's causing this stack trace? The customer is on MacOS 26.1.",
|
||||
use: "What's causing this stack trace? The customer is on macOS 26.1.",
|
||||
agent: AGENTS.explainer,
|
||||
context: SOURCES.web,
|
||||
},
|
||||
|
|
|
|||
442
apps/web-roo-code/src/components/linear/linear-issue-demo.tsx
Normal file
442
apps/web-roo-code/src/components/linear/linear-issue-demo.tsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ChevronRight, GitPullRequest, Paperclip, Send } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ActivityItem = {
|
||||
id: string
|
||||
kind: "comment" | "event" | "pr-link"
|
||||
author?: string
|
||||
avatarText?: string
|
||||
avatarClassName?: string
|
||||
body: ReactNode
|
||||
timeLabel: string
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)")
|
||||
const onChange = () => setReduced(media.matches)
|
||||
onChange()
|
||||
|
||||
if (typeof media.addEventListener === "function") {
|
||||
media.addEventListener("change", onChange)
|
||||
return () => media.removeEventListener("change", onChange)
|
||||
}
|
||||
|
||||
media.addListener?.(onChange)
|
||||
return () => media.removeListener?.(onChange)
|
||||
}, [])
|
||||
|
||||
return reduced
|
||||
}
|
||||
|
||||
type TypingDotsProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
function TypingDots({ className }: TypingDotsProps): JSX.Element {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-0.5", className)} aria-hidden="true">
|
||||
<span className="h-1 w-1 rounded-full bg-[#8B8D91] animate-pulse [animation-delay:0ms]" />
|
||||
<span className="h-1 w-1 rounded-full bg-[#8B8D91] animate-pulse [animation-delay:180ms]" />
|
||||
<span className="h-1 w-1 rounded-full bg-[#8B8D91] animate-pulse [animation-delay:360ms]" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LinearIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 100 100" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.22541 61.5228c-.2225-.9485.90748-1.5459 1.59638-.857L39.3342 97.1782c.6889.6889.0915 1.8189-.857 1.5964C20.0515 94.4522 5.54779 79.9485 1.22541 61.5228ZM.00189135 46.8891c-.01764375.2833.08887215.5599.28957165.7606L52.3503 99.7085c.2007.2007.4773.3075.7606.2896 2.3692-.1476 4.6938-.46 6.9624-.9259.7645-.157 1.0301-1.0963.4782-1.6481L2.57595 39.4485c-.55186-.5519-1.49117-.2863-1.648174.4782-.465915 2.2686-.77832 4.5932-.92588465 6.9624ZM4.21093 29.7054c-.16649.3738-.08169.8106.20765 1.1l64.77602 64.776c.2894.2894.7262.3742 1.1.2077 1.7861-.7956 3.5171-1.6927 5.1855-2.684.5521-.328.6373-1.0867.1832-1.5407L8.43566 24.3367c-.45409-.4541-1.21271-.3689-1.54074.1832-.99132 1.6684-1.88843 3.3994-2.68399 5.1855ZM12.6587 18.074c-.3701-.3701-.393-.9637-.0443-1.3541C21.7795 6.45931 35.1114 0 49.9519 0 77.5927 0 100 22.4073 100 50.0481c0 14.8405-6.4593 28.1724-16.7199 37.3375-.3903.3487-.984.3258-1.3542-.0443L12.6587 18.074Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type ActivityRowProps = {
|
||||
item: ActivityItem
|
||||
isNew: boolean
|
||||
reduceMotion: boolean
|
||||
}
|
||||
|
||||
function ActivityRow({ item, isNew, reduceMotion }: ActivityRowProps): JSX.Element {
|
||||
let animation = ""
|
||||
if (!reduceMotion && isNew) {
|
||||
animation = "animate-in fade-in slide-in-from-bottom-1 duration-300"
|
||||
}
|
||||
|
||||
// Event items (status changes, etc.) - compact inline format
|
||||
if (item.kind === "event") {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-[13px] text-[#8B8D91]", animation)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold",
|
||||
item.avatarClassName,
|
||||
)}>
|
||||
{item.avatarText}
|
||||
</div>
|
||||
<span className="text-[#F8F8F9]">{item.author}</span>
|
||||
<span>{item.body}</span>
|
||||
<span className="text-[#5C5F66]">·</span>
|
||||
<span>{item.timeLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// PR link events
|
||||
if (item.kind === "pr-link") {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-[13px] text-[#8B8D91]", animation)}>
|
||||
<GitPullRequest className="h-4 w-4 shrink-0 text-emerald-500" />
|
||||
<span>{item.body}</span>
|
||||
<span className="text-[#5C5F66]">·</span>
|
||||
<span>{item.timeLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Comment items - more substantial with message body
|
||||
return (
|
||||
<div className={cn("flex gap-2.5", animation)}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold",
|
||||
item.avatarClassName,
|
||||
)}>
|
||||
{item.avatarText}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<span className="font-medium text-[#F8F8F9]">{item.author}</span>
|
||||
<span className="text-[#5C5F66]">·</span>
|
||||
<span className="text-[#8B8D91]">{item.timeLabel}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[13px] leading-relaxed text-[#D1D2D3]">{item.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type LinearIssueDemoProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LinearIssueDemo({ className }: LinearIssueDemoProps): JSX.Element {
|
||||
const reduceMotion = usePrefersReducedMotion()
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const activityItems: ActivityItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "a1",
|
||||
kind: "comment",
|
||||
author: "Jordan",
|
||||
avatarText: "J",
|
||||
avatarClassName: "bg-amber-600 text-white",
|
||||
body: (
|
||||
<span>
|
||||
<span className="text-indigo-400">@Roo Code</span> Can you implement this feature?
|
||||
</span>
|
||||
),
|
||||
timeLabel: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
kind: "comment",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: <span>Analyzing issue requirements and codebase...</span>,
|
||||
timeLabel: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
kind: "event",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: <span>moved to In Progress</span>,
|
||||
timeLabel: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "a4",
|
||||
kind: "comment",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: <span>Planning implementation: Settings component with light/dark toggle.</span>,
|
||||
timeLabel: "1m ago",
|
||||
},
|
||||
{
|
||||
id: "a5",
|
||||
kind: "comment",
|
||||
author: "Jordan",
|
||||
avatarText: "J",
|
||||
avatarClassName: "bg-amber-600 text-white",
|
||||
body: (
|
||||
<span>
|
||||
<span className="text-indigo-400">@Roo Code</span> Please also add a "system" option
|
||||
that follows OS preference.
|
||||
</span>
|
||||
),
|
||||
timeLabel: "1m ago",
|
||||
},
|
||||
{
|
||||
id: "a6",
|
||||
kind: "comment",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: (
|
||||
<span>
|
||||
Got it! Adding system preference detection using{" "}
|
||||
<code className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-[#F8F8F9]">
|
||||
prefers-color-scheme
|
||||
</code>
|
||||
</span>
|
||||
),
|
||||
timeLabel: "30s ago",
|
||||
},
|
||||
{
|
||||
id: "a7",
|
||||
kind: "pr-link",
|
||||
body: (
|
||||
<span>
|
||||
<span className="text-[#F8F8F9]">Roo Code</span> linked{" "}
|
||||
<span className="text-emerald-400">PR #847</span>
|
||||
</span>
|
||||
),
|
||||
timeLabel: "just now",
|
||||
},
|
||||
{
|
||||
id: "a8",
|
||||
kind: "comment",
|
||||
author: "Roo Code",
|
||||
avatarText: "R",
|
||||
avatarClassName: "bg-indigo-600 text-white",
|
||||
body: (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
PR ready for review:{" "}
|
||||
<span className="text-indigo-400 hover:underline cursor-default">#847</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-[12px]">
|
||||
<div className="flex items-center gap-2 text-emerald-400">
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">feat: add theme toggle with system preference</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[#8B8D91]">+142 -12 · 3 files changed</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
timeLabel: "just now",
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
type DemoPhase =
|
||||
| { kind: "issue" }
|
||||
| { kind: "show"; activityIndex: number }
|
||||
| { kind: "typing"; activityIndex: number }
|
||||
| { kind: "reset" }
|
||||
|
||||
const phases: DemoPhase[] = useMemo(() => {
|
||||
const next: DemoPhase[] = []
|
||||
|
||||
next.push({ kind: "issue" })
|
||||
|
||||
for (let activityIndex = 0; activityIndex < activityItems.length; activityIndex += 1) {
|
||||
const item = activityItems[activityIndex]
|
||||
if (item?.kind === "comment") {
|
||||
next.push({ kind: "typing", activityIndex })
|
||||
}
|
||||
next.push({ kind: "show", activityIndex })
|
||||
}
|
||||
next.push({ kind: "reset" })
|
||||
return next
|
||||
}, [activityItems])
|
||||
|
||||
const lastShowPhaseIndex = useMemo(() => {
|
||||
let lastIndex = -1
|
||||
for (let idx = 0; idx < phases.length; idx += 1) {
|
||||
if (phases[idx]?.kind === "show") lastIndex = idx
|
||||
}
|
||||
return lastIndex
|
||||
}, [phases])
|
||||
|
||||
useEffect(() => {
|
||||
if (reduceMotion) {
|
||||
setStepIndex(lastShowPhaseIndex >= 0 ? lastShowPhaseIndex : 0)
|
||||
return
|
||||
}
|
||||
|
||||
const active = phases[stepIndex] ?? phases.at(0)
|
||||
const isLastMessageShow = active?.kind === "show" && stepIndex === lastShowPhaseIndex
|
||||
const durationMs = (() => {
|
||||
const base = 2000
|
||||
if (active?.kind === "reset") return 500
|
||||
if (active?.kind === "issue") return 1500
|
||||
if (active?.kind === "typing") return 800
|
||||
return isLastMessageShow ? base * 2.5 : base
|
||||
})()
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const nextIndex = (stepIndex + 1) % phases.length
|
||||
setStepIndex(nextIndex)
|
||||
}, durationMs)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [lastShowPhaseIndex, phases, reduceMotion, stepIndex])
|
||||
|
||||
const activePhase = phases[stepIndex] ?? phases.at(0) ?? { kind: "issue" }
|
||||
|
||||
function getVisibleCount(phase: DemoPhase): number {
|
||||
if (phase.kind === "reset" || phase.kind === "issue") return 0
|
||||
if (phase.kind === "typing") return phase.activityIndex
|
||||
return phase.activityIndex + 1
|
||||
}
|
||||
|
||||
const visibleCount = getVisibleCount(activePhase)
|
||||
const visibleActivities = activityItems.slice(0, visibleCount)
|
||||
const typingTarget = activePhase.kind === "typing" ? activityItems[activePhase.activityIndex] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollViewportRef.current
|
||||
if (!viewport) return
|
||||
|
||||
if (activePhase.kind === "reset" || activePhase.kind === "issue" || visibleCount <= 1) {
|
||||
viewport.scrollTo({ top: 0, behavior: "auto" })
|
||||
return
|
||||
}
|
||||
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
})
|
||||
}, [activePhase.kind, reduceMotion, visibleCount])
|
||||
|
||||
const issueVisible = activePhase.kind !== "reset"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full max-w-[540px] h-[520px] sm:h-[560px]", className)}
|
||||
role="img"
|
||||
aria-label="Animated Linear issue showing Roo Code responding to a comment">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#1F2023] shadow-2xl shadow-black/40">
|
||||
{/* Linear-style Header with breadcrumb */}
|
||||
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-2.5 text-[13px]">
|
||||
<LinearIcon className="h-4 w-4 text-[#8B8D91]" />
|
||||
<span className="text-[#8B8D91]">Frontend</span>
|
||||
<ChevronRight className="h-3 w-3 text-[#5C5F66]" />
|
||||
<span className="text-[#F8F8F9]">FE-312</span>
|
||||
<div className="ml-auto flex items-center gap-2 text-[11px] text-[#8B8D91]">
|
||||
<span className="h-2 w-2 rounded-full bg-[#27AE60]" />
|
||||
Live demo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col flex-1 overflow-hidden transition-opacity duration-300 will-change-opacity",
|
||||
issueVisible ? "opacity-100" : "opacity-0",
|
||||
)}>
|
||||
{/* Issue Title */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-lg font-semibold text-[#F8F8F9] leading-tight">
|
||||
Add dark mode toggle to settings
|
||||
</h3>
|
||||
<p className="mt-2 text-[13px] text-[#8B8D91] leading-relaxed">
|
||||
Users should be able to switch between light and dark themes from the settings page. Persist
|
||||
preference to localStorage and apply immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Activity Section */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col border-t border-white/10">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium text-[#F8F8F9]">Activity</span>
|
||||
<span className="text-[12px] text-[#5C5F66]">Unsubscribe</span>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollViewportRef}
|
||||
className="flex-1 overflow-y-auto px-4 pb-3 [scrollbar-width:thin] [scrollbar-color:rgba(255,255,255,0.1)_transparent]">
|
||||
<div className="space-y-3">
|
||||
{visibleActivities.map((item) => (
|
||||
<ActivityRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
reduceMotion={reduceMotion}
|
||||
isNew={
|
||||
activePhase.kind === "show" &&
|
||||
activityItems[activePhase.activityIndex]?.id === item.id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{typingTarget && typingTarget.kind === "comment" && (
|
||||
<div
|
||||
className={cn(
|
||||
reduceMotion ? "" : "animate-in fade-in duration-300",
|
||||
"flex gap-2.5",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold",
|
||||
typingTarget.avatarClassName,
|
||||
)}>
|
||||
{typingTarget.avatarText}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<span className="font-medium text-[#F8F8F9]">
|
||||
{typingTarget.author}
|
||||
</span>
|
||||
<span className="text-[#8B8D91]">typing</span>
|
||||
<TypingDots />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment Input */}
|
||||
<div className="border-t border-white/10 px-4 py-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-3 py-2">
|
||||
<span className="flex-1 text-[13px] text-[#5C5F66]">Leave a comment...</span>
|
||||
<Paperclip className="h-4 w-4 text-[#5C5F66]" />
|
||||
<Send className="h-4 w-4 text-[#5C5F66]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center justify-center border-t border-white/10 px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{activityItems.map((item, idx) => (
|
||||
<span
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"h-1 w-3 rounded-full transition-colors duration-300",
|
||||
Math.max(0, visibleCount - 1) === idx ? "bg-indigo-400" : "bg-white/10",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
"src/workers/countTokens.ts",
|
||||
"src/extension.ts",
|
||||
"scripts/**",
|
||||
"apps/cli/scripts/**",
|
||||
"apps/web-roo-code/next-sitemap.config.cjs"
|
||||
],
|
||||
"workspaces": {
|
||||
|
|
@ -16,7 +17,7 @@
|
|||
"project": ["**/*.ts"]
|
||||
},
|
||||
"webview-ui": {
|
||||
"entry": ["src/index.tsx", "src/browser-panel.tsx"],
|
||||
"entry": ["src/index.tsx"],
|
||||
"project": ["src/**/*.{ts,tsx}", "../src/shared/*.ts"]
|
||||
},
|
||||
"packages/{build,cloud,evals,ipc,telemetry,types}": {
|
||||
|
|
|
|||
10
locales/ca/README.md
generated
10
locales/ca/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> El teu equip de desenvolupament impulsat per IA, directament al teu editor
|
||||
|
||||
## Novetats de la v3.51.0
|
||||
|
||||
- S'ha afegit compatibilitat amb OpenAI GPT-5.4 i GPT-5.3 Chat Latest perquè puguis utilitzar els models de xat més nous d'OpenAI a Roo Code.
|
||||
- Les skills es poden exposar com a ordres slash amb execució de reserva perquè els fluxos de treball reutilitzables s'activin més de pressa.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Idiomes disponibles</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code s'adapta a la teva manera de treballar, no a l'inrevés:
|
|||
- Mode Pregunta: respostes ràpides, explicacions i documents
|
||||
- Mode Depuració: rastrejar problemes, afegir registres, aïllar les causes arrel
|
||||
- Modes personalitzats: crea modes especialitzats per al teu equip o flux de treball
|
||||
- Roomote Control: Roomote Control et permet controlar a distància tasques que s'executen a la teva instància local de VS Code.
|
||||
|
||||
Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personalitzats](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personalitzats](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Tutorials i vídeos de funcionalitats
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-mode
|
|||
| | | |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Instal·lant Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurant perfils</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indexació de la base de codi</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modes personalitzats</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Punts de control</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestió de Context</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modes personalitzats</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Punts de control</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestió de Context</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/de/README.md
generated
10
locales/de/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Dein KI-gestütztes Dev-Team – direkt in deinem Editor
|
||||
|
||||
## Neu in v3.51.0
|
||||
|
||||
- Unterstützung für OpenAI GPT-5.4 und GPT-5.3 Chat Latest hinzugefügt, damit du die neuesten OpenAI-Chatmodelle in Roo Code nutzen kannst.
|
||||
- Skills als Slash-Commands mit Fallback-Ausführung verfügbar gemacht, damit wiederverwendbare Workflows schneller ausgelöst werden können.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Verfügbare Sprachen</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code passt sich an deine Arbeitsweise an, nicht umgekehrt:
|
|||
- Fragen-Modus: schnelle Antworten, Erklärungen und Dokumentationen
|
||||
- Debug-Modus: Probleme aufspüren, Protokolle hinzufügen, Ursachen isolieren
|
||||
- Benutzerdefinierte Modi: erstelle spezialisierte Modi für dein Team oder deinen Workflow
|
||||
- Roomote Control: Mit Roomote Control kannst du Aufgaben in deiner lokalen VS Code-Instanz aus der Ferne steuern.
|
||||
|
||||
Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes) • [Benutzerdefinierte Modi](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes) • [Benutzerdefinierte Modi](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Tutorial- & Feature-Videos
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes
|
|||
| | | |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Code installieren</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Profile konfigurieren</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Codebasis-Indizierung</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Benutzerdefinierte Modi</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Kontextverwaltung</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Benutzerdefinierte Modi</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Kontextverwaltung</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/es/README.md
generated
10
locales/es/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Tu equipo de desarrollo con IA, directamente en tu editor
|
||||
|
||||
## Novedades de la v3.51.0
|
||||
|
||||
- Se añadió compatibilidad con OpenAI GPT-5.4 y GPT-5.3 Chat Latest para que puedas usar los modelos de chat más recientes de OpenAI en Roo Code.
|
||||
- Las skills ahora pueden exponerse como comandos slash con ejecución de respaldo para activar más rápido los flujos de trabajo reutilizables.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Idiomas disponibles</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code se adapta a tu forma de trabajar, no al revés:
|
|||
- Modo Pregunta: respuestas rápidas, explicaciones y documentos
|
||||
- Modo Depuración: rastrear problemas, agregar registros, aislar causas raíz
|
||||
- Modos Personalizados: crea modos especializados para tu equipo o flujo de trabajo
|
||||
- Roomote Control: Roomote Control te permite controlar de forma remota tareas que se ejecutan en tu instancia local de VS Code.
|
||||
|
||||
Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos Personalizados](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos Personalizados](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Tutoriales y vídeos de funcionalidades
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [M
|
|||
| | | |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Instalando Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurando perfiles</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indexación de la base de código</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modos personalizados</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestión de Contexto</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modos personalizados</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestión de Contexto</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/fr/README.md
generated
10
locales/fr/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Ton équipe de dev propulsée par l'IA, directement dans ton éditeur
|
||||
|
||||
## Nouveautés de la v3.51.0
|
||||
|
||||
- Ajout de la prise en charge d’OpenAI GPT-5.4 et de GPT-5.3 Chat Latest pour que tu puisses utiliser les modèles de chat OpenAI les plus récents dans Roo Code.
|
||||
- Les skills peuvent désormais être exposées comme slash commands avec une exécution de secours pour déclencher plus vite des workflows réutilisables.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Langues disponibles</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code s'adapte à votre façon de travailler, pas l'inverse :
|
|||
- Mode Demande : réponses rapides, explications et documents
|
||||
- Mode Débogage : tracer les problèmes, ajouter des journaux, isoler les causes profondes
|
||||
- Modes Personnalisés : créez des modes spécialisés pour votre équipe ou votre flux de travail
|
||||
- Roomote Control : Roomote Control te permet de piloter à distance les tâches exécutées dans ton instance VS Code locale.
|
||||
|
||||
En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personnalisés](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personnalisés](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Tutoriels & Vidéos de fonctionnalités
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using
|
|||
| | | |
|
||||
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Installer Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurer les profils</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indexation de la base de code</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modes personnalisés</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestion du Contexte</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modes personnalisés</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestion du Contexte</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/hi/README.md
generated
10
locales/hi/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> तुम्हारी AI-संचालित डेवलपमेंट टीम, सीधे तुम्हारे एडिटर में
|
||||
|
||||
## v3.51.0 में नया क्या है
|
||||
|
||||
- OpenAI GPT-5.4 और GPT-5.3 Chat Latest के लिए सपोर्ट जोड़ा गया है, ताकि आप Roo Code में OpenAI के सबसे नए chat models इस्तेमाल कर सकें।
|
||||
- अब skills को slash commands के रूप में fallback execution के साथ expose किया जा सकता है, जिससे reusable workflows को तेज़ी से trigger किया जा सके।
|
||||
|
||||
<details>
|
||||
<summary>🌐 उपलब्ध भाषाएँ</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@
|
|||
- पूछें मोड: त्वरित उत्तर, स्पष्टीकरण और डॉक्स
|
||||
- डीबग मोड: समस्याओं का पता लगाएं, लॉग जोड़ें, मूल कारणों को अलग करें
|
||||
- कस्टम मोड: अपनी टीम या वर्कफ़्लो के लिए विशेष मोड बनाएं
|
||||
- Roomote Control: Roomote Control से तुम अपनी लोकल VS Code इंस्टेंस में चल रही टास्क को रिमोट से कंट्रोल कर सकते हो।
|
||||
|
||||
और जानो: [मोड्स का इस्तेमाल](https://docs.roocode.com/basic-usage/using-modes) • [कस्टम मोड्स](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
और जानो: [मोड्स का इस्तेमाल](https://docs.roocode.com/basic-usage/using-modes) • [कस्टम मोड्स](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## ट्यूटोरियल और फ़ीचर वीडियो
|
||||
|
||||
|
|
@ -69,7 +73,7 @@
|
|||
| | | |
|
||||
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>रू कोड इंस्टॉल करना</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>प्रोफाइल कॉन्फ़िगर करना</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>कोडबेस इंडेक्सिंग</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>कस्टम मोड</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>चेकपॉइंट्स</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>संदर्भ प्रबंधन</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>कस्टम मोड</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>चेकपॉइंट्स</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>संदर्भ प्रबंधन</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/id/README.md
generated
10
locales/id/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Tim dev bertenaga AI-mu, langsung di editor kamu
|
||||
|
||||
## Yang Baru di v3.51.0
|
||||
|
||||
- Menambahkan dukungan untuk OpenAI GPT-5.4 dan GPT-5.3 Chat Latest agar kamu bisa memakai model chat OpenAI terbaru di Roo Code.
|
||||
- Skill kini bisa diekspos sebagai slash command dengan eksekusi fallback supaya workflow yang bisa dipakai ulang lebih cepat dijalankan.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Bahasa yang tersedia</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code beradaptasi dengan cara Anda bekerja, bukan sebaliknya:
|
|||
- Mode Tanya: jawaban cepat, penjelasan, dan dokumen
|
||||
- Mode Debug: melacak masalah, menambahkan log, mengisolasi akar penyebab
|
||||
- Mode Kustom: buat mode khusus untuk tim atau alur kerja Anda
|
||||
- Roomote Control: Roomote Control memungkinkan kamu mengontrol dari jarak jauh tugas yang berjalan di VS Code lokalmu.
|
||||
|
||||
Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/using-modes) • [Mode Kustom](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/using-modes) • [Mode Kustom](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Video Tutorial & Fitur
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/u
|
|||
| | | |
|
||||
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Menginstal Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Mengonfigurasi Profil</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Pengindeksan Basis Kode</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Mode Kustom</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Pos Pemeriksaan</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Manajemen Konteks</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Mode Kustom</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Pos Pemeriksaan</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Manajemen Konteks</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/it/README.md
generated
10
locales/it/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Il tuo team di sviluppo con IA, direttamente nel tuo editor
|
||||
|
||||
## Novità in v3.51.0
|
||||
|
||||
- Aggiunto il supporto per OpenAI GPT-5.4 e GPT-5.3 Chat Latest così puoi usare i modelli di chat OpenAI più recenti in Roo Code.
|
||||
- Le skill ora possono essere esposte come slash command con esecuzione di fallback per attivare più rapidamente i workflow riutilizzabili.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Lingue disponibili</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code si adatta al tuo modo di lavorare, non il contrario:
|
|||
- Modalità Chiedi: risposte rapide, spiegazioni e documenti
|
||||
- Modalità Debug: traccia problemi, aggiungi log, isola le cause principali
|
||||
- Modalità Personalizzate: crea modalità specializzate per il tuo team o flusso di lavoro
|
||||
- Roomote Control: Roomote Control ti permette di controllare da remoto le attività in esecuzione sulla tua istanza locale di VS Code.
|
||||
|
||||
Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-modes) • [Modalità personalizzate](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-modes) • [Modalità personalizzate](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Tutorial e video sulle funzionalità
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-
|
|||
| | | |
|
||||
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Installazione di Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurazione dei profili</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indicizzazione della codebase</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modalità personalizzate</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoint</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestione del Contesto</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modalità personalizzate</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoint</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gestione del Contesto</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/ja/README.md
generated
10
locales/ja/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> あなたのエディタの中に、AIで強化された開発チームを
|
||||
|
||||
## v3.51.0 の新機能
|
||||
|
||||
- OpenAI GPT-5.4 と GPT-5.3 Chat Latest のサポートを追加し、Roo Code で OpenAI の最新チャットモデルを使えるようにしました。
|
||||
- スキルをスラッシュコマンドとして公開し、フォールバック実行にも対応したことで、再利用可能なワークフローをより素早く呼び出せます。
|
||||
|
||||
<details>
|
||||
<summary>🌐 利用可能な言語</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Codeは、あなたの働き方に合わせるように適応します。
|
|||
- 質問モード:迅速な回答、説明、ドキュメント
|
||||
- デバッグモード:問題の追跡、ログの追加、根本原因の特定
|
||||
- カスタムモード:チームやワークフローに特化したモードの構築
|
||||
- Roomote Control: Roomote Control はローカルの VS Code で実行中のタスクをリモート操作できます。
|
||||
|
||||
詳しくは: [モードの使い方](https://docs.roocode.com/basic-usage/using-modes) • [カスタムモード](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
詳しくは: [モードの使い方](https://docs.roocode.com/basic-usage/using-modes) • [カスタムモード](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## チュートリアルと機能のビデオ
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Roo Codeは、あなたの働き方に合わせるように適応します。
|
|||
| | | |
|
||||
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Codeのインストール</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>プロファイルの設定</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>コードベースのインデックス作成</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>カスタムモード</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>チェックポイント</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>コンテキスト管理</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>カスタムモード</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>チェックポイント</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>コンテキスト管理</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/ko/README.md
generated
10
locales/ko/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> AI로 강화된 너의 개발 팀, 네 에디터 안에
|
||||
|
||||
## v3.51.0의 새로운 기능
|
||||
|
||||
- OpenAI GPT-5.4와 GPT-5.3 Chat Latest 지원을 추가해 Roo Code에서 최신 OpenAI 채팅 모델을 사용할 수 있어요.
|
||||
- 이제 스킬을 슬래시 명령어로 노출하고 fallback 실행도 지원해 재사용 가능한 워크플로를 더 빠르게 트리거할 수 있어요.
|
||||
|
||||
<details>
|
||||
<summary>🌐 사용 가능한 언어</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다.
|
|||
- 질문 모드: 빠른 답변, 설명 및 문서
|
||||
- 디버그 모드: 문제 추적, 로그 추가, 근본 원인 격리
|
||||
- 사용자 지정 모드: 팀이나 워크플로우를 위한 특수 모드 구축
|
||||
- Roomote Control: Roomote Control은 로컬 VS Code 인스턴스에서 실행 중인 작업을 원격으로 제어할 수 있어.
|
||||
|
||||
자세히: [모드 사용](https://docs.roocode.com/basic-usage/using-modes) • [사용자 지정 모드](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
자세히: [모드 사용](https://docs.roocode.com/basic-usage/using-modes) • [사용자 지정 모드](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## 튜토리얼 및 기능 비디오
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다.
|
|||
| | | |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Code 설치하기</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>프로필 구성하기</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>코드베이스 인덱싱</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>사용자 지정 모드</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>체크포인트</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>컨텍스트 관리</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>사용자 지정 모드</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>체크포인트</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>컨텍스트 관리</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
8
locales/nl/README.md
generated
8
locales/nl/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Jouw AI-aangedreven dev-team, rechtstreeks in je editor
|
||||
|
||||
## Nieuw in v3.51.0
|
||||
|
||||
- Ondersteuning toegevoegd voor OpenAI GPT-5.4 en GPT-5.3 Chat Latest, zodat je de nieuwste OpenAI-chatmodellen in Roo Code kunt gebruiken.
|
||||
- Skills beschikbaar gemaakt als slash-commands met fallback-uitvoering, zodat herbruikbare workflows sneller te starten zijn.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Beschikbare talen</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code past zich aan jouw werkwijze aan, niet andersom:
|
|||
- Vraag Modus: snelle antwoorden, uitleg en documenten
|
||||
- Debug Modus: spoor problemen op, voeg logs toe, isoleer de oorzaak
|
||||
- Aangepaste Modi: bouw gespecialiseerde modi voor je team of workflow
|
||||
- Roomote Control: Roomote Control laat je taken op je lokale VS Code-instantie op afstand besturen.
|
||||
|
||||
Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes) • [Aangepaste modi](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes) • [Aangepaste modi](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Tutorial & Feature Videos
|
||||
|
||||
|
|
|
|||
10
locales/pl/README.md
generated
10
locales/pl/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Twój zespół deweloperski zasilany AI — prosto w edytorze
|
||||
|
||||
## Nowości w v3.51.0
|
||||
|
||||
- Dodano obsługę OpenAI GPT-5.4 i GPT-5.3 Chat Latest, żebyś mógł używać najnowszych modeli czatu OpenAI w Roo Code.
|
||||
- Skills można teraz udostępniać jako slash commandy z wykonaniem awaryjnym, żeby szybciej uruchamiać wielokrotnego użytku workflowy.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Dostępne języki</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code dostosowuje się do Twojego sposobu pracy, a nie odwrotnie:
|
|||
- Tryb Zapytaj: szybkie odpowiedzi, wyjaśnienia i dokumenty
|
||||
- Tryb Debugowanie: śledzenie problemów, dodawanie logów, izolowanie przyczyn źródłowych
|
||||
- Tryby niestandardowe: buduj specjalistyczne tryby dla swojego zespołu lub przepływu pracy
|
||||
- Roomote Control: Roomote Control pozwala zdalnie sterować zadaniami uruchomionymi na twojej lokalnej instancji VS Code.
|
||||
|
||||
Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-modes) • [Tryby niestandardowe](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-modes) • [Tryby niestandardowe](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Filmy instruktażowe i prezentujące funkcje
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-mode
|
|||
| | | |
|
||||
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Instalacja Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Konfiguracja profili</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indeksowanie bazy kodu</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Tryby niestandardowe</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Punkty kontrolne</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Zarządzanie Kontekstem</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Tryby niestandardowe</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Punkty kontrolne</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Zarządzanie Kontekstem</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/pt-BR/README.md
generated
10
locales/pt-BR/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Seu time de desenvolvimento com IA, direto no seu editor
|
||||
|
||||
## Novidades na v3.51.0
|
||||
|
||||
- Adicionamos suporte ao OpenAI GPT-5.4 e ao GPT-5.3 Chat Latest para você usar os modelos de chat mais novos da OpenAI no Roo Code.
|
||||
- Agora as skills podem ser expostas como comandos slash com execução de fallback para acionar workflows reutilizáveis mais rápido.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Idiomas disponíveis</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ O Roo Code se adapta à sua maneira de trabalhar, e não o contrário:
|
|||
- Modo Pergunta: respostas rápidas, explicações e documentos
|
||||
- Modo Depuração: rastreie problemas, adicione logs, isole as causas raiz
|
||||
- Modos Personalizados: crie modos especializados para sua equipe ou fluxo de trabalho
|
||||
- Roomote Control: O Roomote Control permite controlar remotamente tarefas em execução na sua instância local do VS Code.
|
||||
|
||||
Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos personalizados](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos personalizados](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Vídeos de tutorial e recursos
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [
|
|||
| | | |
|
||||
| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Instalando o Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configurando perfis</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Indexação da base de código</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modos personalizados</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gerenciamento de Contexto</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Modos personalizados</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Gerenciamento de Contexto</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
10
locales/ru/README.md
generated
10
locales/ru/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Твоя команда разработчиков на ИИ — прямо в редакторе
|
||||
|
||||
## Что нового в v3.51.0
|
||||
|
||||
- Добавлена поддержка OpenAI GPT-5.4 и GPT-5.3 Chat Latest, чтобы ты мог использовать новейшие чат-модели OpenAI в Roo Code.
|
||||
- Skills теперь можно открывать как slash-команды с резервным выполнением, чтобы быстрее запускать переиспользуемые рабочие процессы.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Доступные языки</summary>
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code адаптируется к вашему стилю работы, а н
|
|||
- Режим Вопрос: быстрые ответы, объяснения и документация
|
||||
- Режим Отладка: отслеживание проблем, добавление логов, изоляция первопричин
|
||||
- Пользовательские режимы: создавайте специализированные режимы для вашей команды или рабочего процесса
|
||||
- Roomote Control: Roomote Control позволяет удаленно управлять задачами, запущенными в вашей локальной инстансе VS Code.
|
||||
|
||||
Подробнее: [Использование режимов](https://docs.roocode.com/basic-usage/using-modes) • [Пользовательские режимы](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Подробнее: [Использование режимов](https://docs.roocode.com/basic-usage/using-modes) • [Пользовательские режимы](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Обучающие видео и видео о функциях
|
||||
|
||||
|
|
@ -69,7 +73,7 @@ Roo Code адаптируется к вашему стилю работы, а н
|
|||
| | | |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Установка Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Настройка профилей</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Индексация кодовой базы</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Пользовательские режимы</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Контрольные точки</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Управление Контекстом</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Пользовательские режимы</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Контрольные точки</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Управление Контекстом</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
16
locales/tr/README.md
generated
16
locales/tr/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> AI destekli dev ekibin, doğrudan editörünün içinde
|
||||
|
||||
## v3.51.0'daki Yenilikler
|
||||
|
||||
- Roo Code içinde en yeni OpenAI sohbet modellerini kullanabilmen için OpenAI GPT-5.4 ve GPT-5.3 Chat Latest desteği eklendi.
|
||||
- Skills artık fallback yürütmeyle slash komutları olarak sunulabiliyor, böylece yeniden kullanılabilir iş akışları daha hızlı tetiklenebiliyor.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Mevcut diller</summary>
|
||||
|
||||
|
|
@ -58,18 +63,17 @@ Roo Code, sizin çalışma şeklinize uyum sağlar, tam tersi değil:
|
|||
- Sor Modu: hızlı cevaplar, açıklamalar ve belgeler
|
||||
- Hata Ayıklama Modu: sorunları izleyin, günlükler ekleyin, kök nedenleri izole edin
|
||||
- Özel Modlar: ekibiniz veya iş akışınız için özel modlar oluşturun
|
||||
- Roomote Control: Roomote Control, yerel VS Code örneğinde çalışan işleri uzaktan kontrol etmeni sağlar.
|
||||
|
||||
Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes) • [Özel modlar](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes) • [Özel modlar](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Eğitim ve Özellik Videoları
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | | |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Code Kurulumu</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Profilleri Yapılandırma</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Kod Tabanı İndeksleme</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Özel Modlar</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Kontrol Noktaları</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Bağlam Yönetimi</b> |
|
||||
| | | |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Roo Code Kurulumu</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Profilleri Yapılandırma</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Kod Tabanı İndeksleme</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Özel Modlar</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Kontrol Noktaları</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Bağlam Yönetimi</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
16
locales/vi/README.md
generated
16
locales/vi/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> Đội ngũ dev dùng AI của bạn, ngay trong trình chỉnh sửa
|
||||
|
||||
## Điểm mới trong v3.51.0
|
||||
|
||||
- Đã thêm hỗ trợ cho OpenAI GPT-5.4 và GPT-5.3 Chat Latest để bạn có thể dùng các mô hình chat OpenAI mới nhất trong Roo Code.
|
||||
- Skills giờ có thể được cung cấp dưới dạng lệnh slash kèm thực thi dự phòng để kích hoạt các quy trình tái sử dụng nhanh hơn.
|
||||
|
||||
<details>
|
||||
<summary>🌐 Các ngôn ngữ có sẵn</summary>
|
||||
|
||||
|
|
@ -58,18 +63,17 @@ Roo Code thích ứng với cách bạn làm việc, chứ không phải ngượ
|
|||
- Chế độ Hỏi: câu trả lời nhanh, giải thích và tài liệu
|
||||
- Chế độ Gỡ lỗi: theo dõi sự cố, thêm nhật ký, cô lập nguyên nhân gốc rễ
|
||||
- Chế độ Tùy chỉnh: xây dựng các chế độ chuyên biệt cho nhóm hoặc quy trình làm việc của bạn
|
||||
- Roomote Control: Roomote Control cho phép bạn điều khiển từ xa các tác vụ đang chạy trên VS Code cục bộ của bạn.
|
||||
|
||||
Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-modes) • [Chế độ tùy chỉnh](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-modes) • [Chế độ tùy chỉnh](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## Video hướng dẫn & tính năng
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | | |
|
||||
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Cài đặt Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Định cấu hình Hồ sơ</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Lập chỉ mục cơ sở mã</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Chế độ tùy chỉnh</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Điểm kiểm tra</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Quản lý Ngữ cảnh</b> |
|
||||
| | | |
|
||||
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Cài đặt Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Định cấu hình Hồ sơ</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Lập chỉ mục cơ sở mã</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Chế độ tùy chỉnh</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Điểm kiểm tra</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Quản lý Ngữ cảnh</b> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
|
|
|
|||
14
locales/zh-CN/README.md
generated
14
locales/zh-CN/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> 你的 AI 驱动开发团队,就在你的编辑器里
|
||||
|
||||
## v3.51.0 新增内容
|
||||
|
||||
- 已添加对 OpenAI GPT-5.4 和 GPT-5.3 Chat Latest 的支持,让你可以在 Roo Code 中使用 OpenAI 最新的聊天模型。
|
||||
- 现在可以将 skills 公开为 slash 命令,并支持 fallback execution,让可复用工作流触发得更快。
|
||||
|
||||
<details>
|
||||
<summary>🌐 可用语言</summary>
|
||||
|
||||
|
|
@ -58,17 +63,16 @@ Roo Code 适应您的工作方式,而不是相反:
|
|||
- 提问模式:快速回答、解释和文档
|
||||
- 调试模式:跟踪问题、添加日志、隔离根本原因
|
||||
- 自定义模式:为您的团队或工作流程构建专门的模式
|
||||
- Roomote Control:Roomote Control 允许你远程控制在本地 VS Code 实例中运行的任务。
|
||||
|
||||
了解更多:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自定义模式](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
了解更多:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自定义模式](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## 教程和功能视频
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | | |
|
||||
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>安装 Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>配置个人资料</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>代码库索引</b> |
|
||||
| | | |
|
||||
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: |
|
||||
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>安装 Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>配置个人资料</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>代码库索引</b> |
|
||||
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>自定义模式</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>检查点</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>上下文管理</b> |
|
||||
|
||||
</div>
|
||||
|
|
|
|||
10
locales/zh-TW/README.md
generated
10
locales/zh-TW/README.md
generated
|
|
@ -13,6 +13,11 @@
|
|||
|
||||
> 您的 AI 驅動開發團隊,就在您的編輯器中
|
||||
|
||||
## v3.51.0 新功能
|
||||
|
||||
- 已新增對 OpenAI GPT-5.4 和 GPT-5.3 Chat Latest 的支援,讓你可以在 Roo Code 中使用 OpenAI 最新的聊天模型。
|
||||
- 現在可以將 skills 公開為 slash 指令,並支援 fallback execution,讓可重用工作流程觸發得更快。
|
||||
|
||||
<details>
|
||||
<summary>🌐 支援語言</summary>
|
||||
|
||||
|
|
@ -35,7 +40,7 @@
|
|||
- [简体中文](../zh-CN/README.md)
|
||||
- [繁體中文](../zh-TW/README.md)
|
||||
- ...
|
||||
</details>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -58,9 +63,8 @@ Roo Code 會配合您的工作方式,而非要您配合它:
|
|||
- 詢問模式:快速回答、解釋和文件
|
||||
- 偵錯模式:追蹤問題、新增日誌、鎖定根本原因
|
||||
- 自訂模式:為您的團隊或工作流程建置專門的模式
|
||||
- Roomote Control:Roomote Control 讓您能遠端控制在本機 VS Code 執行個體中運行的工作。
|
||||
|
||||
更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes)
|
||||
|
||||
## 教學和功能影片
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
"ioredis": "^5.6.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -69,9 +69,9 @@ export class CloudSettingsService extends EventEmitter<SettingsServiceEvents> im
|
|||
callback: async () => {
|
||||
return await this.fetchSettings()
|
||||
},
|
||||
successInterval: 30000,
|
||||
successInterval: 3_600_000,
|
||||
initialBackoffMs: 1000,
|
||||
maxBackoffMs: 30000,
|
||||
maxBackoffMs: 3_600_000,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,15 +42,12 @@ export class StaticSettingsService implements SettingsService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns static user settings with roomoteControlEnabled and extensionBridgeEnabled as true
|
||||
* Returns static user settings with task sync enabled
|
||||
*/
|
||||
public getUserSettings(): UserSettingsData | undefined {
|
||||
return {
|
||||
features: {
|
||||
roomoteControlEnabled: true,
|
||||
},
|
||||
features: {},
|
||||
settings: {
|
||||
extensionBridgeEnabled: true,
|
||||
taskSyncEnabled: true,
|
||||
},
|
||||
version: 1,
|
||||
|
|
@ -58,14 +55,11 @@ export class StaticSettingsService implements SettingsService {
|
|||
}
|
||||
|
||||
public getUserFeatures(): UserFeatures {
|
||||
return {
|
||||
roomoteControlEnabled: true,
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
public getUserSettingsConfig(): UserSettingsConfig {
|
||||
return {
|
||||
extensionBridgeEnabled: true,
|
||||
taskSyncEnabled: true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ export class StaticTokenAuthService extends EventEmitter<AuthServiceEvents> impl
|
|||
this.userInfo = {
|
||||
id: payload?.r?.u || payload?.sub || undefined,
|
||||
organizationId: payload?.r?.o || undefined,
|
||||
extensionBridgeEnabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -194,6 +194,10 @@ export class CloudTelemetryClient extends BaseTelemetryClient {
|
|||
}
|
||||
|
||||
public async backfillMessages(messages: ClineMessage[], taskId: string): Promise<void> {
|
||||
if (!this.isTelemetryEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.authService.isAuthenticated()) {
|
||||
if (this.debug) {
|
||||
console.info(`[TelemetryClient#backfillMessages] Skipping: Not authenticated`)
|
||||
|
|
@ -260,6 +264,10 @@ export class CloudTelemetryClient extends BaseTelemetryClient {
|
|||
public override updateTelemetryState(_didUserOptIn: boolean) {}
|
||||
|
||||
public override isTelemetryEnabled(): boolean {
|
||||
if (process.env.ROO_CODE_DISABLE_TELEMETRY === "1") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -625,8 +625,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
)?.email_address
|
||||
}
|
||||
|
||||
let extensionBridgeEnabled = true
|
||||
|
||||
// Fetch organization info if user is in organization context
|
||||
try {
|
||||
const storedOrgId = this.getStoredOrganizationId()
|
||||
|
|
@ -641,8 +639,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
if (userMembership) {
|
||||
this.setUserOrganizationInfo(userInfo, userMembership)
|
||||
|
||||
extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(storedOrgId)
|
||||
|
||||
this.log("[auth] User in organization context:", {
|
||||
id: userMembership.organization.id,
|
||||
name: userMembership.organization.name,
|
||||
|
|
@ -662,10 +658,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
if (primaryOrgMembership) {
|
||||
this.setUserOrganizationInfo(userInfo, primaryOrgMembership)
|
||||
|
||||
extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(
|
||||
primaryOrgMembership.organization.id,
|
||||
)
|
||||
|
||||
this.log("[auth] Legacy credentials: Found organization membership:", {
|
||||
id: primaryOrgMembership.organization.id,
|
||||
name: primaryOrgMembership.organization.name,
|
||||
|
|
@ -680,9 +672,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
// Don't throw - organization info is optional
|
||||
}
|
||||
|
||||
// Set the extension bridge enabled flag
|
||||
userInfo.extensionBridgeEnabled = extensionBridgeEnabled
|
||||
|
||||
return userInfo
|
||||
}
|
||||
|
||||
|
|
@ -729,36 +718,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
private async getOrganizationMetadata(
|
||||
organizationId: string,
|
||||
): Promise<{ public_metadata?: Record<string, unknown> } | null> {
|
||||
try {
|
||||
const response = await fetch(`${getClerkBaseUrl()}/v1/organizations/${organizationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.credentials!.clientToken}`,
|
||||
"User-Agent": this.userAgent(),
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.log(`[auth] Failed to fetch organization metadata: ${response.status} ${response.statusText}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.response || data
|
||||
} catch (error) {
|
||||
this.log("[auth] Error fetching organization metadata:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async isExtensionBridgeEnabledForOrganization(organizationId: string): Promise<boolean> {
|
||||
const orgMetadata = await this.getOrganizationMetadata(organizationId)
|
||||
return orgMetadata?.public_metadata?.extension_bridge_enabled === true
|
||||
}
|
||||
|
||||
private async clerkLogout(credentials: AuthCredentials): Promise<void> {
|
||||
const formData = new URLSearchParams()
|
||||
formData.append("_is_native", "1")
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
// npx vitest run src/__tests__/CloudService.integration.test.ts
|
||||
|
||||
import type { ExtensionContext } from "vscode"
|
||||
|
||||
import { CloudService } from "../CloudService.js"
|
||||
import { StaticSettingsService } from "../StaticSettingsService.js"
|
||||
import { CloudSettingsService } from "../CloudSettingsService.js"
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
ExtensionContext: vi.fn(),
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
},
|
||||
env: {
|
||||
openExternal: vi.fn(),
|
||||
},
|
||||
Uri: {
|
||||
parse: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("CloudService Integration - Settings Service Selection", () => {
|
||||
let mockContext: ExtensionContext
|
||||
|
||||
beforeEach(() => {
|
||||
CloudService.resetInstance()
|
||||
|
||||
mockContext = {
|
||||
subscriptions: [],
|
||||
workspaceState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
secrets: {
|
||||
get: vi.fn(),
|
||||
store: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
|
||||
},
|
||||
globalState: {
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setKeysForSync: vi.fn(),
|
||||
keys: vi.fn().mockReturnValue([]),
|
||||
},
|
||||
extensionUri: { scheme: "file", path: "/mock/path" },
|
||||
extensionPath: "/mock/path",
|
||||
extensionMode: 1,
|
||||
asAbsolutePath: vi.fn((relativePath: string) => `/mock/path/${relativePath}`),
|
||||
storageUri: { scheme: "file", path: "/mock/storage" },
|
||||
extension: {
|
||||
packageJSON: {
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
} as unknown as ExtensionContext
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
CloudService.resetInstance()
|
||||
delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS
|
||||
delete process.env.ROO_CODE_CLOUD_TOKEN
|
||||
})
|
||||
|
||||
it("should use CloudSettingsService when no environment variable is set", async () => {
|
||||
// Ensure no environment variables are set
|
||||
delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS
|
||||
delete process.env.ROO_CODE_CLOUD_TOKEN
|
||||
|
||||
const cloudService = await CloudService.createInstance(mockContext)
|
||||
|
||||
// Access the private settingsService to check its type
|
||||
const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
|
||||
expect(settingsService).toBeInstanceOf(CloudSettingsService)
|
||||
})
|
||||
|
||||
it("should use StaticSettingsService when ROO_CODE_CLOUD_ORG_SETTINGS is set", async () => {
|
||||
const validSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
enableTaskSharing: true,
|
||||
taskShareExpirationDays: 30,
|
||||
},
|
||||
defaultSettings: {
|
||||
enableCheckpoints: true,
|
||||
},
|
||||
allowList: {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
},
|
||||
}
|
||||
|
||||
// Set the environment variable
|
||||
process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64")
|
||||
|
||||
const cloudService = await CloudService.createInstance(mockContext)
|
||||
|
||||
// Access the private settingsService to check its type
|
||||
const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
|
||||
expect(settingsService).toBeInstanceOf(StaticSettingsService)
|
||||
|
||||
// Verify the settings are correctly loaded
|
||||
expect(cloudService.getAllowList()).toEqual(validSettings.allowList)
|
||||
})
|
||||
|
||||
it("should throw error when ROO_CODE_CLOUD_ORG_SETTINGS contains invalid data", async () => {
|
||||
// Set invalid environment variable
|
||||
process.env.ROO_CODE_CLOUD_ORG_SETTINGS = "invalid-base64-data"
|
||||
|
||||
await expect(CloudService.createInstance(mockContext)).rejects.toThrow("Failed to initialize CloudService")
|
||||
})
|
||||
|
||||
it("should prioritize static token auth when both environment variables are set", async () => {
|
||||
const validSettings = {
|
||||
version: 1,
|
||||
cloudSettings: {
|
||||
recordTaskMessages: true,
|
||||
enableTaskSharing: true,
|
||||
taskShareExpirationDays: 30,
|
||||
},
|
||||
defaultSettings: {
|
||||
enableCheckpoints: true,
|
||||
},
|
||||
allowList: {
|
||||
allowAll: true,
|
||||
providers: {},
|
||||
},
|
||||
}
|
||||
|
||||
// Set both environment variables
|
||||
process.env.ROO_CODE_CLOUD_TOKEN = "test-token"
|
||||
process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64")
|
||||
|
||||
const cloudService = await CloudService.createInstance(mockContext)
|
||||
|
||||
// Should use StaticSettingsService for settings
|
||||
const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService
|
||||
expect(settingsService).toBeInstanceOf(StaticSettingsService)
|
||||
|
||||
// Should use StaticTokenAuthService for auth (from the existing logic)
|
||||
expect(cloudService.isAuthenticated()).toBe(true)
|
||||
expect(cloudService.hasActiveSession()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -105,12 +105,8 @@ describe("CloudSettingsService - Response Parsing", () => {
|
|||
},
|
||||
},
|
||||
user: {
|
||||
features: {
|
||||
roomoteControlEnabled: true,
|
||||
},
|
||||
settings: {
|
||||
extensionBridgeEnabled: true,
|
||||
},
|
||||
features: {},
|
||||
settings: {},
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,9 +89,9 @@ describe("CloudSettingsService", () => {
|
|||
expect(cloudSettingsService).toBeInstanceOf(CloudSettingsService)
|
||||
expect(RefreshTimer).toHaveBeenCalledWith({
|
||||
callback: expect.any(Function),
|
||||
successInterval: 30000,
|
||||
successInterval: 3_600_000,
|
||||
initialBackoffMs: 1000,
|
||||
maxBackoffMs: 30000,
|
||||
maxBackoffMs: 3_600_000,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,6 @@ describe("StaticTokenAuthService", () => {
|
|||
const userInfo = serviceWithJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC")
|
||||
expect(userInfo?.organizationId).toBe("org_123abc")
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse job token without orgId (null orgId case)", () => {
|
||||
|
|
@ -98,7 +97,6 @@ describe("StaticTokenAuthService", () => {
|
|||
const userInfo = serviceWithJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC")
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse auth token and extract userId from r.u", () => {
|
||||
|
|
@ -107,7 +105,6 @@ describe("StaticTokenAuthService", () => {
|
|||
const userInfo = serviceWithAuthToken.getUserInfo()
|
||||
expect(userInfo?.id).toBe("user_123")
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle legacy JWT format with sub field", () => {
|
||||
|
|
@ -116,7 +113,6 @@ describe("StaticTokenAuthService", () => {
|
|||
const userInfo = serviceWithLegacyJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBe("user_123")
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle invalid JWT gracefully", () => {
|
||||
|
|
@ -125,7 +121,6 @@ describe("StaticTokenAuthService", () => {
|
|||
const userInfo = serviceWithInvalidJWT.getUserInfo()
|
||||
expect(userInfo?.id).toBeUndefined()
|
||||
expect(userInfo?.organizationId).toBeUndefined()
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse JWT:", expect.any(Error))
|
||||
})
|
||||
|
|
@ -183,9 +178,7 @@ describe("StaticTokenAuthService", () => {
|
|||
authService.broadcast()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
userInfo: expect.objectContaining({
|
||||
extensionBridgeEnabled: true,
|
||||
}),
|
||||
userInfo: expect.objectContaining({}),
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -199,7 +192,6 @@ describe("StaticTokenAuthService", () => {
|
|||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
userInfo: {
|
||||
extensionBridgeEnabled: true,
|
||||
id: "user_2xmBhejNeDTwanM8CgIOnMgVxzC",
|
||||
organizationId: "org_123abc",
|
||||
},
|
||||
|
|
@ -220,10 +212,9 @@ describe("StaticTokenAuthService", () => {
|
|||
})
|
||||
|
||||
describe("getUserInfo", () => {
|
||||
it("should return object with extensionBridgeEnabled flag", () => {
|
||||
it("should return user info object", () => {
|
||||
const userInfo = authService.getUserInfo()
|
||||
expect(userInfo).toHaveProperty("extensionBridgeEnabled")
|
||||
expect(userInfo?.extensionBridgeEnabled).toBe(true)
|
||||
expect(userInfo).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -305,9 +296,7 @@ describe("StaticTokenAuthService", () => {
|
|||
})
|
||||
|
||||
expect(userInfoSpy).toHaveBeenCalledWith({
|
||||
userInfo: expect.objectContaining({
|
||||
extensionBridgeEnabled: true,
|
||||
}),
|
||||
userInfo: expect.objectContaining({}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -636,7 +636,6 @@ describe("WebAuthService", () => {
|
|||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
picture: "https://example.com/avatar.jpg",
|
||||
extensionBridgeEnabled: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -801,7 +800,6 @@ describe("WebAuthService", () => {
|
|||
name: "Jane Smith",
|
||||
email: "jane@example.com",
|
||||
picture: "https://example.com/jane.jpg",
|
||||
extensionBridgeEnabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -869,7 +867,6 @@ describe("WebAuthService", () => {
|
|||
name: "Jane Smith",
|
||||
email: "jane@example.com",
|
||||
picture: "https://example.com/jane.jpg",
|
||||
extensionBridgeEnabled: false,
|
||||
organizationId: "org_1",
|
||||
organizationName: "Org 1",
|
||||
organizationRole: "member",
|
||||
|
|
@ -920,7 +917,6 @@ describe("WebAuthService", () => {
|
|||
name: "John Doe",
|
||||
email: undefined,
|
||||
picture: undefined,
|
||||
extensionBridgeEnabled: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1045,7 +1041,6 @@ describe("WebAuthService", () => {
|
|||
name: "Test User",
|
||||
email: undefined,
|
||||
picture: undefined,
|
||||
extensionBridgeEnabled: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
import type { Socket } from "socket.io-client"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import type { StaticAppProperties, GitProperties } from "@roo-code/types"
|
||||
|
||||
export interface BaseChannelOptions {
|
||||
instanceId: string
|
||||
appProperties: StaticAppProperties
|
||||
gitProperties?: GitProperties
|
||||
isCloudAgent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for communication channels in the bridge system.
|
||||
* Provides common functionality for bidirectional communication between
|
||||
* the VSCode extension and web application.
|
||||
*
|
||||
* @template TCommand - Type of commands this channel can receive.
|
||||
* @template TEvent - Type of events this channel can publish.
|
||||
*/
|
||||
export abstract class BaseChannel<TCommand = unknown, TEventName extends string = string, TEventData = unknown> {
|
||||
protected socket: Socket | null = null
|
||||
protected readonly instanceId: string
|
||||
protected readonly appProperties: StaticAppProperties
|
||||
protected readonly gitProperties?: GitProperties
|
||||
protected readonly isCloudAgent: boolean
|
||||
|
||||
constructor(options: BaseChannelOptions) {
|
||||
this.instanceId = options.instanceId
|
||||
this.appProperties = options.appProperties
|
||||
this.gitProperties = options.gitProperties
|
||||
this.isCloudAgent = options.isCloudAgent
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when socket connects.
|
||||
*/
|
||||
public async onConnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
await this.handleConnect(socket)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when socket disconnects.
|
||||
*/
|
||||
public onDisconnect(): void {
|
||||
this.socket = null
|
||||
this.handleDisconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when socket reconnects.
|
||||
*/
|
||||
public async onReconnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
await this.handleReconnect(socket)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources.
|
||||
*/
|
||||
public async cleanup(socket: Socket | null): Promise<void> {
|
||||
if (socket) {
|
||||
await this.handleCleanup(socket)
|
||||
}
|
||||
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a socket event with error handling.
|
||||
*/
|
||||
protected publish<Params extends object>(
|
||||
eventName: TEventName,
|
||||
data: TEventData,
|
||||
callback?: (params: Params) => void,
|
||||
): boolean {
|
||||
if (!this.socket) {
|
||||
console.error(`[${this.constructor.name}#emit] socket not available for ${eventName}`)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log(`[${this.constructor.name}#emit] emit() -> ${eventName}`, data)
|
||||
this.socket.emit(eventName, data, callback)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[${this.constructor.name}#emit] emit() failed -> ${eventName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming commands - template method that ensures common functionality
|
||||
* is executed before subclass-specific logic.
|
||||
*
|
||||
* This method should be called by subclasses to handle commands.
|
||||
* It will execute common functionality and then delegate to the abstract
|
||||
* handleCommandImplementation method.
|
||||
*/
|
||||
public async handleCommand(command: TCommand): Promise<void> {
|
||||
// Common functionality: focus the sidebar.
|
||||
await vscode.commands.executeCommand(`${this.appProperties.appName}.SidebarProvider.focus`)
|
||||
|
||||
// Delegate to subclass-specific implementation.
|
||||
await this.handleCommandImplementation(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle command-specific logic - must be implemented by subclasses.
|
||||
* This method is called after common functionality has been executed.
|
||||
*/
|
||||
protected abstract handleCommandImplementation(command: TCommand): Promise<void>
|
||||
|
||||
/**
|
||||
* Handle connection-specific logic.
|
||||
*/
|
||||
protected abstract handleConnect(socket: Socket): Promise<void>
|
||||
|
||||
/**
|
||||
* Handle disconnection-specific logic.
|
||||
*/
|
||||
protected handleDisconnect(): void {
|
||||
// Default implementation - can be overridden.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reconnection-specific logic.
|
||||
*/
|
||||
protected abstract handleReconnect(socket: Socket): Promise<void>
|
||||
|
||||
/**
|
||||
* Handle cleanup-specific logic.
|
||||
*/
|
||||
protected abstract handleCleanup(socket: Socket): Promise<void>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue