mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Compare commits
123 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b867ec9145 | ||
|
|
8b94decaef | ||
|
|
f49f0ce56a | ||
|
|
d82583c91b | ||
|
|
6ae816f244 | ||
|
|
f5cad409a1 | ||
|
|
15542fcbbf | ||
|
|
d229002cdd | ||
|
|
06777a513e | ||
|
|
5641ccd58d | ||
|
|
e921f9d21e | ||
|
|
28acb6acf2 | ||
|
|
2428199851 | ||
|
|
8922418600 | ||
|
|
22d845cecb | ||
|
|
ff16c9c297 | ||
|
|
3d37e054dd | ||
|
|
ad25634905 | ||
|
|
96d6e43643 | ||
|
|
14922f127e | ||
|
|
142f3fb335 | ||
|
|
c4547d25c5 | ||
|
|
b4f2a242bc | ||
|
|
2bb826039b | ||
|
|
3e202ebf5b | ||
|
|
cb83656718 | ||
|
|
8b12f21439 | ||
|
|
9f251fde70 | ||
|
|
319b5576c7 | ||
|
|
7adbfec2a4 | ||
|
|
9cfaf38d8a | ||
|
|
92a1be40df | ||
|
|
5b93bc7dc3 | ||
|
|
5432fa2689 | ||
|
|
eafed9705c | ||
|
|
c3cae397a1 | ||
|
|
137d3f4fd8 | ||
|
|
08f3a2bb39 | ||
|
|
44fd975b17 | ||
|
|
4ab1d81f55 | ||
|
|
7ae9c4efd2 | ||
|
|
0892455db2 | ||
|
|
0e56afc764 | ||
|
|
5eec588653 | ||
|
|
58c535319e | ||
|
|
0612739ba1 | ||
|
|
52ce796e42 | ||
|
|
3e237e6061 | ||
|
|
0b0b33e6a2 | ||
|
|
0db51d8bfa | ||
|
|
7dc83a522e | ||
|
|
9a58f76299 | ||
|
|
f9da48f73a | ||
|
|
06afe4206b | ||
|
|
02598bc4a5 | ||
|
|
d0480360cb | ||
|
|
459f27015d | ||
|
|
d6611f8f69 | ||
|
|
af1e12c76d | ||
|
|
ce73d05646 | ||
|
|
3c7544c104 | ||
|
|
7ea91fa79e | ||
|
|
941e6de743 | ||
|
|
95ea01f9a5 | ||
|
|
5a4ab2b13f | ||
|
|
b00c260dc0 | ||
|
|
737edbc5ab | ||
|
|
eb2147148e | ||
|
|
7118a14188 | ||
|
|
571cae2368 | ||
|
|
e6ad7949d6 | ||
|
|
a7cebac2d8 | ||
|
|
787f02e526 | ||
|
|
ec979585e8 | ||
|
|
b75f484663 | ||
|
|
52d606bab3 | ||
|
|
193053110b | ||
|
|
54721a3155 | ||
|
|
b55409c0b0 | ||
|
|
50c8101754 | ||
|
|
a7c8275e85 | ||
|
|
81047a65b6 | ||
|
|
2f4ce367c4 | ||
|
|
96d77fd2c8 | ||
|
|
de0e3632d2 | ||
|
|
e25b1f2768 | ||
|
|
bcdc842eda | ||
|
|
4e8cc6eaee | ||
|
|
0aea65d1e6 | ||
|
|
efbba9e8d5 | ||
|
|
191053cec5 | ||
|
|
70cbc716e8 | ||
|
|
6f65a26694 | ||
|
|
2541449924 | ||
|
|
d0e72b7b3e | ||
|
|
b73dc15fda | ||
|
|
48d7e29ed8 | ||
|
|
29caab9d0d | ||
|
|
aca95ccd04 | ||
|
|
ab61ee2cd6 | ||
|
|
62a7bd7354 | ||
|
|
2df6e38998 | ||
|
|
ae09ee5a64 | ||
|
|
6b3097cb31 | ||
|
|
6bd6dc61a4 | ||
|
|
a55c85a8ec | ||
|
|
2991bc9a39 | ||
|
|
93415c7204 | ||
|
|
4288b0a72f | ||
|
|
9918e837ba | ||
|
|
b34678488e | ||
|
|
618aa6652b | ||
|
|
27095553ca | ||
|
|
3a7a01f2f7 | ||
|
|
492006d53d | ||
|
|
5db2062d0c | ||
|
|
0d5b932d2e | ||
|
|
318bb928e0 | ||
|
|
ea7da97a40 | ||
|
|
9a8af61936 | ||
|
|
d00f1a2bb8 | ||
|
|
159bf2e9f1 | ||
|
|
d8cfbfdb05 |
1966 changed files with 62605 additions and 88996 deletions
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
"roo-cline": minor
|
||||
---
|
||||
|
||||
- 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)
|
||||
15
.changeset/v3.54.0.md
Normal file
15
.changeset/v3.54.0.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
"roo-cline": minor
|
||||
---
|
||||
|
||||
- Remove: Roo Code Cloud and eval infrastructure from the extension, CLI, workflows, and package surfaces so the release is focused on the standalone extension (PR #12328 by @mrubens)
|
||||
- Remove: All telemetry collection and analytics plumbing across the extension, website, shared types, provider flows, and related tests (PR #12324 by @mrubens)
|
||||
- Remove: MDM and organization membership enforcement, including host wiring, webview state, user-facing messages, and locale strings (PR #12323 by @mrubens)
|
||||
- Remove: The MCP marketplace, marketplace services, webview marketplace UI, package contributions, and related localized copy (PR #12326 by @mrubens)
|
||||
- Update: Extension-facing support, diagnostics, and announcement content for the final Roo Code release, including GitHub help paths and links to Roomote, ZooCode, and Cline (PR #12341 by @brunobergher)
|
||||
- Add: A cleaned docs app with GitHub Pages deployment support (PR #12344 by @brunobergher)
|
||||
- Fix: Configure the docs GitHub Pages base URL so deployed assets and canonical paths load correctly under the repository Pages path (PR #12370 by @mrubens)
|
||||
- Update: Point docs links in the root README, localized READMEs, and web app copy to the current GitHub Pages docs URL (PR #12371 by @mrubens)
|
||||
- Remove: Stale `roocode.github.io` docs references, including the old CNAME and outdated docs README and robots.txt URLs (PR #12372 by @mrubens)
|
||||
- Update: The website to focus almost entirely on the Roo Code extension and remove cloud, team, enterprise, provider, pricing, Slack, and Linear product pages (PR #12180 by @brunobergher)
|
||||
- Remove: Contributor, community, social channel, and tutorial references from README files, docs, website copy, issue templates, and workflows (PR #12347 by @brunobergher)
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -64,7 +64,7 @@ body:
|
|||
attributes:
|
||||
value: |
|
||||
---
|
||||
Optional (for contributors): You can stop here if you're just proposing the improvement.
|
||||
Optional: You can stop here if you're just proposing the improvement.
|
||||
|
||||
- type: textarea
|
||||
id: acceptance-criteria
|
||||
|
|
|
|||
75
.github/pull_request_template.md
vendored
75
.github/pull_request_template.md
vendored
|
|
@ -1,75 +0,0 @@
|
|||
<!--
|
||||
Thank you for contributing to Roo Code!
|
||||
|
||||
Before submitting your PR, please ensure:
|
||||
- It's linked to an approved GitHub Issue.
|
||||
- You've reviewed our [Contributing Guidelines](../CONTRIBUTING.md).
|
||||
-->
|
||||
|
||||
### Related GitHub Issue
|
||||
|
||||
<!-- Every PR MUST be linked to an approved issue. -->
|
||||
|
||||
Closes: # <!-- Replace with the issue number, e.g., Closes: #123 -->
|
||||
|
||||
### Roo Code Task Context (Optional)
|
||||
|
||||
<!--
|
||||
If you used Roo Code to help create this PR, you can share public task links here.
|
||||
This helps reviewers understand your development process and provides additional context.
|
||||
Example: https://app.roocode.com/share/task-id
|
||||
-->
|
||||
|
||||
### Description
|
||||
|
||||
<!--
|
||||
Briefly summarize the changes in this PR and how they address the linked issue.
|
||||
The issue should cover the "what" and "why"; this section should focus on:
|
||||
- The "how": key implementation details, design choices, or trade-offs made.
|
||||
- Anything specific reviewers should pay attention to in this PR.
|
||||
-->
|
||||
|
||||
### Test Procedure
|
||||
|
||||
<!--
|
||||
Detail the steps to test your changes. This helps reviewers verify your work.
|
||||
- How did you test this specific implementation? (e.g., unit tests, manual testing steps)
|
||||
- How can reviewers reproduce your tests or verify the fix/feature?
|
||||
- Include relevant testing environment details if applicable.
|
||||
-->
|
||||
|
||||
### Pre-Submission Checklist
|
||||
|
||||
<!-- Go through this checklist before marking your PR as ready for review. -->
|
||||
|
||||
- [ ] **Issue Linked**: This PR is linked to an approved GitHub Issue (see "Related GitHub Issue" above).
|
||||
- [ ] **Scope**: My changes are focused on the linked issue (one major feature/fix per PR).
|
||||
- [ ] **Self-Review**: I have performed a thorough self-review of my code.
|
||||
- [ ] **Testing**: New and/or updated tests have been added to cover my changes (if applicable).
|
||||
- [ ] **Documentation Impact**: I have considered if my changes require documentation updates (see "Documentation Updates" section below).
|
||||
- [ ] **Contribution Guidelines**: I have read and agree to the [Contributor Guidelines](/CONTRIBUTING.md).
|
||||
|
||||
### Screenshots / Videos
|
||||
|
||||
<!--
|
||||
For UI changes, please provide before-and-after screenshots or a short video of the *actual results*.
|
||||
This greatly helps in understanding the visual impact of your changes.
|
||||
-->
|
||||
|
||||
### Documentation Updates
|
||||
|
||||
<!--
|
||||
Does this PR necessitate updates to user-facing documentation?
|
||||
- [ ] No documentation updates are required.
|
||||
- [ ] Yes, documentation updates are required. (Please describe what needs to be updated or link to a PR in the docs repository).
|
||||
-->
|
||||
|
||||
### Additional Notes
|
||||
|
||||
<!-- Add any other context, questions, or information for reviewers here. -->
|
||||
|
||||
### Get in Touch
|
||||
|
||||
<!--
|
||||
Please provide your Discord username for reviewers or maintainers to reach you if they have questions about your PR
|
||||
-->
|
||||
55
.github/workflows/docs-pages.yml
vendored
Normal file
55
.github/workflows/docs-pages.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
name: Deploy docs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/docs/**"
|
||||
- ".github/workflows/docs-pages.yml"
|
||||
- ".github/actions/setup-node-pnpm/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: docs-pages
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
with:
|
||||
install-args: "--frozen-lockfile"
|
||||
- name: Run type check
|
||||
run: pnpm --filter @roo-code/docs check-types
|
||||
- name: Run lint
|
||||
run: pnpm --filter @roo-code/docs lint
|
||||
- name: Build docs
|
||||
run: pnpm --filter @roo-code/docs build
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: apps/docs/build
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
74
.github/workflows/evals.yml
vendored
74
.github/workflows/evals.yml
vendored
|
|
@ -1,74 +0,0 @@
|
|||
name: Evals
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
|
||||
jobs:
|
||||
evals:
|
||||
# Run if triggered manually or if PR has 'evals' label.
|
||||
if: github.event_name == 'workflow_dispatch' || contains(github.event.label.name, 'evals')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/evals
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create environment
|
||||
run: |
|
||||
cat > .env.local << EOF
|
||||
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY || 'test-key-for-build' }}
|
||||
EOF
|
||||
|
||||
cat > .env.development << EOF
|
||||
NODE_ENV=development
|
||||
DATABASE_URL=postgresql://postgres:password@db:5432/evals_development
|
||||
REDIS_URL=redis://redis:6379
|
||||
HOST_EXECUTION_METHOD=docker
|
||||
EOF
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: packages/evals/Dockerfile.runner
|
||||
tags: evals-runner:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
push: false
|
||||
load: true
|
||||
|
||||
- name: Tag image
|
||||
run: docker tag evals-runner:latest evals-runner
|
||||
|
||||
- name: Start containers
|
||||
run: |
|
||||
docker compose up -d db redis
|
||||
timeout 60 bash -c 'until docker compose exec -T db pg_isready -U postgres; do sleep 2; done'
|
||||
timeout 60 bash -c 'until docker compose exec -T redis redis-cli ping | grep -q PONG; do sleep 2; done'
|
||||
docker compose run --rm runner sh -c 'nc -z db 5432 && echo "✓ Runner -> Database connection successful"'
|
||||
docker compose run --rm runner sh -c 'nc -z redis 6379 && echo "✓ Runner -> Redis connection successful"'
|
||||
docker compose run --rm runner docker ps
|
||||
|
||||
- name: Run database migrations
|
||||
run: docker compose run --rm runner pnpm --filter @roo-code/evals db:migrate
|
||||
|
||||
- name: Run evals
|
||||
run: docker compose run --rm runner pnpm --filter @roo-code/evals cli --ci
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: docker compose down -v --remove-orphans
|
||||
67
.github/workflows/update-contributors.yml
vendored
67
.github/workflows/update-contributors.yml
vendored
|
|
@ -1,67 +0,0 @@
|
|||
name: Update Contributors # Refresh contrib.rocks image cache
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
refresh-contrib-cache:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Bump cacheBust in all README files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TS="$(date +%s)"
|
||||
# Target only the root README.md and localized READMEs under locales/*/README.md
|
||||
mapfile -t FILES < <(git ls-files README.md 'locales/*/README.md' || true)
|
||||
|
||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||
echo "No target README files found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UPDATED=0
|
||||
for f in "${FILES[@]}"; do
|
||||
if grep -q 'cacheBust=' "$f"; then
|
||||
# Use portable sed in GNU environment of ubuntu-latest
|
||||
sed -i -E "s/cacheBust=[0-9]+/cacheBust=${TS}/g" "$f"
|
||||
echo "Updated cacheBust in $f"
|
||||
UPDATED=1
|
||||
else
|
||||
echo "Warning: cacheBust parameter not found in $f" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$UPDATED" -eq 0 ]; then
|
||||
echo "No files were updated. Ensure READMEs embed contrib.rocks with cacheBust param." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Detect changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet; then
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "docs: update contributors list [skip ci]"
|
||||
committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
|
||||
branch: refresh-contrib-cache
|
||||
delete-branch: true
|
||||
title: "Refresh contrib.rocks image cache (all READMEs)"
|
||||
body: |
|
||||
Automated refresh of the contrib.rocks image cache by bumping the cacheBust parameter in README.md and locales/*/README.md.
|
||||
base: main
|
||||
59
.github/workflows/website-deploy.yml
vendored
59
.github/workflows/website-deploy.yml
vendored
|
|
@ -1,59 +0,0 @@
|
|||
name: Deploy roocode.com
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'apps/web-roo-code/**'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-roocode-com
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
check-secrets:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has-vercel-token: ${{ steps.check.outputs.has-vercel-token }}
|
||||
steps:
|
||||
- name: Check if VERCEL_TOKEN exists
|
||||
id: check
|
||||
run: |
|
||||
if [ -n "${{ secrets.VERCEL_TOKEN }}" ]; then
|
||||
echo "has-vercel-token=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has-vercel-token=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-secrets
|
||||
if: ${{ needs.check-secrets.outputs.has-vercel-token == 'true' }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Run lint
|
||||
run: pnpm lint
|
||||
working-directory: apps/web-roo-code
|
||||
- name: Run type check
|
||||
run: pnpm check-types
|
||||
working-directory: apps/web-roo-code
|
||||
- name: Run build
|
||||
run: pnpm build
|
||||
working-directory: apps/web-roo-code
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
- name: Pull Vercel Environment Information
|
||||
run: npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Build Project Artifacts
|
||||
run: npx vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
run: npx vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||
102
.github/workflows/website-preview.yml
vendored
102
.github/workflows/website-preview.yml
vendored
|
|
@ -1,102 +0,0 @@
|
|||
name: Preview roocode.com
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
paths:
|
||||
- "apps/web-roo-code/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/web-roo-code/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: preview-roocode-com-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
check-secrets:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has-vercel-token: ${{ steps.check.outputs.has-vercel-token }}
|
||||
steps:
|
||||
- name: Check if VERCEL_TOKEN exists
|
||||
id: check
|
||||
run: |
|
||||
if [ -n "${{ secrets.VERCEL_TOKEN }}" ]; then
|
||||
echo "has-vercel-token=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has-vercel-token=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
preview:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-secrets
|
||||
if: ${{ needs.check-secrets.outputs.has-vercel-token == 'true' }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js and pnpm
|
||||
uses: ./.github/actions/setup-node-pnpm
|
||||
- name: Run lint
|
||||
run: pnpm lint
|
||||
working-directory: apps/web-roo-code
|
||||
- name: Run type check
|
||||
run: pnpm check-types
|
||||
working-directory: apps/web-roo-code
|
||||
- name: Run build
|
||||
run: pnpm build
|
||||
working-directory: apps/web-roo-code
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
- name: Pull Vercel Environment Information
|
||||
run: npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Build Project Artifacts
|
||||
run: npx vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
DEPLOYMENT_URL=$(npx vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
|
||||
echo "deployment_url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT
|
||||
echo "Preview deployed to: $DEPLOYMENT_URL"
|
||||
|
||||
- name: Comment PR with preview link
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const deploymentUrl = '${{ steps.deploy.outputs.deployment_url }}';
|
||||
const commentIdentifier = '<!-- roo-preview-comment -->';
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existingComment = comments.find(comment =>
|
||||
comment.body.includes(commentIdentifier)
|
||||
);
|
||||
|
||||
const comment = commentIdentifier + '\n🚀 **Preview deployed!**\n\nYour changes have been deployed to Vercel:\n\n**Preview URL:** ' + deploymentUrl + '\n\nThis preview will be updated automatically when you push new commits to this PR.';
|
||||
|
||||
if (existingComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
body: comment
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
pnpm 10.8.1
|
||||
nodejs 20.19.2
|
||||
|
|
|
|||
164
CHANGELOG.md
164
CHANGELOG.md
|
|
@ -1,24 +1,152 @@
|
|||
# Roo Code Changelog
|
||||
|
||||
## 3.49.0
|
||||
## 3.53.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- **The Roo Code plugin is not going away.** You may have seen the [recent announcement](https://x.com/mattrubens/status/2046636598859559114) that Roo Code hit 3 million installs and the original team is going all-in on Roomote. We know that news was hard for a lot of you. This plugin means a lot to us and to you, and we hear you. The good news: a community team has stepped up to carry Roo Code forward, and we're working with them on an official handoff so the plugin you rely on keeps getting maintained and improved.
|
||||
- Add GPT-5.5 support via the OpenAI Codex provider (PR #12170 by @hannesrudolph)
|
||||
- Add Claude Opus 4.7 support on Vertex AI (#12134 by @saneroen, PR #12135 by @saneroen)
|
||||
- Add previous checkpoint navigation controls and i18n in chat (#12138 by @saneroen, PR #12139 by @saneroen)
|
||||
- Add Roomote banner (PR #12119 by @brunobergher)
|
||||
- Redesign Roomote announcement banner with violet branding on the web (PR #12161 by @roomote-v0)
|
||||
- Add sunsetting Roo Code blog post (PR #12160 by @roomote-v0)
|
||||
|
||||
## 3.52.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add correct JSON schema for `.roomodes` configuration files (#11790 by @algorhythm85, PR #11791 by @app/roomote-v0)
|
||||
- Remove the hiring announcement from the VS Code extension UI (PR #12108 by @app/roomote-v0)
|
||||
|
||||
## 3.52.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add Poe as an AI provider so users can access Poe models directly in Roo Code (PR #12015 by @kamilio)
|
||||
- Improve the xAI provider by migrating it to the Responses API with reusable transform utilities (#11961 by @carlesso, PR #11962 by @carlesso)
|
||||
- Fix MiniMax model listings and context window handling for more reliable configuration (#11999 by @Rexarrior, PR #12069 by @Rexarrior)
|
||||
- Add xAI Grok-4.20 models and update the default xAI model selection (#11955 by @carlesso, PR #11956 by @carlesso)
|
||||
- Add OpenAI GPT-5.4 mini and nano models to expand the available OpenAI model lineup (PR #11946 by @PeterDaveHello)
|
||||
- Chore: include the automated version bump PR from the previous release cycle for complete release accounting (PR #11892 by @app/github-actions)
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add support for OpenAI `gpt-5.4-mini` and `gpt-5.4-nano` models.
|
||||
|
||||
## 3.51.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Feat: Add Cohere Embed v4 model support for Bedrock and improve credential handling (#11823 by @cscvenkatmadurai, PR #11824 by @cscvenkatmadurai)
|
||||
- Feat: Add Gemini 3.1 Pro customtools model to Vertex AI provider (PR #11857 by @NVolcz)
|
||||
- Feat: Add gpt-5.4 to ChatGPT Plus/Pro (Codex) model catalog (PR #11876 by @roomote-v0)
|
||||
|
||||
## 3.51.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add OpenAI GPT-5.4 and GPT-5.3 Chat Latest model support so Roo Code can use the newest OpenAI chat models (PR #11848 by @PeterDaveHello)
|
||||
- Add support for exposing skills as slash commands with skill fallback execution for faster workflows (PR #11834 by @hannesrudolph)
|
||||
- Add CLI support for `--create-with-session-id` plus UUID session validation for more controlled session creation (PR #11859 by @cte)
|
||||
- Add support for choosing a specific shell when running terminal commands (PR #11851 by @jr)
|
||||
- Feature: Add the `ROO_ACTIVE` environment variable to terminal session settings for safer terminal guardrails (#11864 by @ajjuaire, PR #11862 by @ajjuaire)
|
||||
- Improve cloud settings freshness by updating the refresh interval to one hour (PR #11749 by @roomote-v0)
|
||||
- Add CLI session resume/history support plus an upgrade command for better long-running workflows (PR #11768 by @cte)
|
||||
- Add support for images in CLI stdin stream commands (PR #11831 by @cte)
|
||||
- Include `exitCode` in CLI command `tool_result` events for more reliable automation (PR #11820 by @cte)
|
||||
- Add CLI types to improve development ergonomics and type safety (PR #11781 by @cte)
|
||||
- Add CLI integration coverage for stdin stream routing and race-condition invariants (PR #11846 by @cte)
|
||||
- Fix the CLI stdin-stream cancel race and add an integration test suite to prevent regressions (PR #11817 by @cte)
|
||||
- Improve CLI stream recovery and add a configurable consecutive mistake limit (PR #11775 by @cte)
|
||||
- Fix CLI streaming deltas, task ID propagation, cancel recovery, and other runtime edge cases (PR #11736 by @cte)
|
||||
- Fix CLI task resumption so paused work can reliably continue (PR #11739 by @cte)
|
||||
- Recover from unhandled exceptions in the CLI instead of failing hard (PR #11750 by @cte)
|
||||
- Scope CLI session and resume flags to the current workspace to avoid cross-workspace confusion (PR #11774 by @cte)
|
||||
- Fix stdin prompt streaming to forward task configuration correctly (PR #11778 by @daniel-lxs)
|
||||
- Handle stdin-stream control-flow errors gracefully in the CLI runtime (PR #11811 by @cte)
|
||||
- Fix stdin stream queued messages and command output streaming in the CLI (PR #11814 by @cte)
|
||||
- Increase the CLI command execution timeout for long-running commands (PR #11815 by @cte)
|
||||
- Fix knip checks to keep repository validation green (PR #11819 by @cte)
|
||||
- Fix CLI upgrade version detection so upgrades resolve the correct target version (PR #11829 by @cte)
|
||||
- Ignore model-provided timeout values in the CLI runtime to keep command handling consistent (PR #11835 by @cte)
|
||||
- Fix redundant skill reloading during conversations to reduce duplicate work (PR #11838 by @hannesrudolph)
|
||||
- Ensure full command output is streamed before the CLI reports completion (PR #11842 by @cte)
|
||||
- Fix CLI follow-up routing after completion prompts so next actions land in the right place (PR #11844 by @cte)
|
||||
- Remove the Netflix logo from the homepage (PR #11787 by @roomote-v0)
|
||||
- Chore: Prepare CLI release v0.1.2 (PR #11737 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.3 (PR #11740 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.4 (PR #11751 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.5 (PR #11772 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.6 (PR #11780 by @cte)
|
||||
- Release Roo Code v1.113.0 (PR #11782 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.7 (PR #11812 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.8 (PR #11816 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.9 (PR #11818 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.10 (PR #11821 by @cte)
|
||||
- Release Roo Code v1.114.0 (PR #11822 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.11 (PR #11832 by @cte)
|
||||
- Release Roo Code v1.115.0 (PR #11833 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.12 (PR #11836 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.13 (PR #11837 by @hannesrudolph)
|
||||
- Chore: Prepare CLI release v0.1.14 (PR #11843 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.15 (PR #11845 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.16 (PR #11852 by @cte)
|
||||
- Chore: Prepare CLI release v0.1.17 (PR #11860 by @cte)
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add OpenAI's GPT-5.3-Chat-Latest model support
|
||||
- Add OpenAI's GPT-5.3-Codex model support
|
||||
- Add OpenAI's GPT-5.4 model support
|
||||
- Add OpenAI's GPT-5.3-Codex model support (PR #11728 by @PeterDaveHello)
|
||||
- Warm Roo models on CLI startup for faster initial responses (PR #11722 by @cte)
|
||||
- Fix spelling/grammar and casing inconsistencies (#11478 by @PeterDaveHello, PR #11485 by @PeterDaveHello)
|
||||
- Fix: Restore Linear integration page (PR #11725 by @roomote)
|
||||
- Chore: Prepare CLI release v0.1.1 (PR #11723 by @cte)
|
||||
|
||||
## [3.50.4] - 2026-02-21
|
||||
|
||||
- Feat: Add MiniMax M2.5 model support (#11471 by @love8ko, PR #11458 by @roomote)
|
||||
|
||||
## [3.50.3] - 2026-02-20
|
||||
|
||||
- Fix: Correct Vertex AI claude-sonnet-4-6 model ID (#11625 by @yuvarajl, PR #11626 by @roomote)
|
||||
- Restore Unbound as a provider (PR #11624 by @pugazhendhi-m)
|
||||
|
||||
## [3.50.2] - 2026-02-20
|
||||
|
||||
- Fix: Inline terminal rendering parity with the VSCode Terminal (#10699 by @jerrill-johnson-bitwerx, PR #11361 by @RussellZager)
|
||||
- Fix: Enable prompt caching for Bedrock custom ARN and default to ON (#10846 by @wisestmumbler, PR #11373 by @roomote)
|
||||
- Feat: Add visual feedback to copy button in task actions (#11401 by @omagoduck, PR #11403 by @omagoduck)
|
||||
|
||||
## [3.50.1] - 2026-02-20
|
||||
|
||||
- Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed.
|
||||
|
||||
## [3.50.0] - 2026-02-19
|
||||
|
||||
- Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello)
|
||||
- Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte)
|
||||
- Prepare CLI v0.1.0 release (PR #11599 by @cte)
|
||||
- Remove integration tests (PR #11598 by @roomote)
|
||||
- Changeset version bump (PR #11596 by @github-actions)
|
||||
|
||||
## [3.49.0] - 2026-02-19
|
||||
|
||||
- Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen)
|
||||
- Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20)
|
||||
- Add per-task file-based history store for cross-instance safety (PR #11490 by @roomote)
|
||||
- Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph)
|
||||
- Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote)
|
||||
|
||||
## 3.48.1
|
||||
|
||||
### Patch Changes
|
||||
## [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]
|
||||
## [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)
|
||||
|
|
@ -539,7 +667,7 @@
|
|||
- Refactor: Consolidate ThinkingBudget components and fix disable handling (PR #9930 by @hannesrudolph)
|
||||
- Forbid time estimates in architect mode for more focused planning (PR #9931 by @app/roomote)
|
||||
- Web: Add product pages (PR #9865 by @brunobergher)
|
||||
- Make eval runs deleteable in the web UI (PR #9909 by @mrubens)
|
||||
- Make eval runs deletable in the web UI (PR #9909 by @mrubens)
|
||||
- Feat: Change defaultToolProtocol default from xml to native (later reverted) (PR #9892 by @app/roomote)
|
||||
|
||||
## [3.36.2] - 2025-12-04
|
||||
|
|
@ -1146,7 +1274,6 @@
|
|||
- Reposition Add Image button inside ChatTextArea (thanks @roomote!)
|
||||
- Bring back a way to temporarily and globally pause auto-approve without losing your toggle state (thanks @brunobergher!)
|
||||
- Makes text area buttons appear only when there's text (thanks @brunobergher!)
|
||||
- CONTRIBUTING.md tweaks and issue template rewrite (thanks @hannesrudolph!)
|
||||
- Bump axios from 1.9.0 to 1.12.0 (thanks @dependabot!)
|
||||
|
||||
## [3.28.2] - 2025-09-14
|
||||
|
|
@ -1587,7 +1714,7 @@
|
|||
- Add: Mistral embedding provider (thanks @SannidhyaSah!)
|
||||
- Fix: add run parameter to vitest command in rules (thanks @KJ7LNW!)
|
||||
- Update: the max_tokens fallback logic in the sliding window
|
||||
- Fix: Bedrock and Vertext token counting improvements (thanks @daniel-lxs!)
|
||||
- Fix: Bedrock and Vertex token counting improvements (thanks @daniel-lxs!)
|
||||
- Add: llama-4-maverick model to Vertex AI provider (thanks @MuriloFP!)
|
||||
- Fix: properly distinguish between user cancellations and API failures
|
||||
- Fix: add case sensitivity mention to suggested fixes in apply_diff error message
|
||||
|
|
@ -1606,7 +1733,6 @@
|
|||
- Fix Claude model detection by name for API protocol selection (thanks @daniel-lxs!)
|
||||
- Move marketplace icon from overflow menu to top navigation
|
||||
- Optional setting to prevent completion with open todos
|
||||
- Added YouTube to website footer (thanks @thill2323!)
|
||||
|
||||
## [3.23.14] - 2025-07-17
|
||||
|
||||
|
|
@ -1897,7 +2023,7 @@
|
|||
- Sync BatchDiffApproval styling with BatchFilePermission for UI consistency (thanks @samhvw8!)
|
||||
- Add max height constraint to MCP execution response for better UX (thanks @samhvw8!)
|
||||
- Prevent MCP 'installed' label from being squeezed #4630 (thanks @daniel-lxs!)
|
||||
- Allow a lower context condesning threshold (thanks @SECKainersdorfer!)
|
||||
- Allow a lower context condensing threshold (thanks @SECKainersdorfer!)
|
||||
- Avoid type system duplication for cleaner codebase (thanks @EamonNerbonne!)
|
||||
|
||||
## [3.20.1] - 2025-06-12
|
||||
|
|
@ -2000,7 +2126,6 @@
|
|||
- Fix bug with context condensing in Amazon Bedrock
|
||||
- Fix UTF-8 encoding in ExecaTerminalProcess (thanks @mr-ryan-james!)
|
||||
- Set sidebar name bugfix (thanks @chrarnoldus!)
|
||||
- Fix link to CONTRIBUTING.md in feature request template (thanks @cannuri!)
|
||||
- Add task metadata to Unbound and improve caching logic (thanks @pugazhendhi-m!)
|
||||
|
||||
## [3.19.0] - 2025-05-29
|
||||
|
|
@ -2054,7 +2179,7 @@
|
|||
|
||||
## [3.18.2] - 2025-05-23
|
||||
|
||||
- Fix vscode-material-icons in the filer picker
|
||||
- Fix vscode-material-icons in the file picker
|
||||
- Fix global settings export
|
||||
- Respect user-configured terminal integration timeout (thanks @KJ7LNW)
|
||||
- Context condensing enhancements (thanks @SannidhyaSah)
|
||||
|
|
@ -2172,7 +2297,7 @@
|
|||
- Add vertical tab navigation to the settings (thanks @dlab-anton)
|
||||
- Add Groq and Chutes API providers (thanks @shariqriazz)
|
||||
- Clickable code references in code block (thanks @KJ7LNW)
|
||||
- Improve accessibility of ato-approve toggles (thanks @Deon588)
|
||||
- Improve accessibility of auto-approve toggles (thanks @Deon588)
|
||||
- Requesty provider fixes (thanks @dtrugman)
|
||||
- Fix migration and persistence of per-mode API profiles (thanks @alasano)
|
||||
- Fix usage of `path.basename` in the extension webview (thanks @samhvw8)
|
||||
|
|
@ -2234,7 +2359,7 @@
|
|||
- Fix file mentions for filenames containing spaces
|
||||
- Improve the auto-approve toggle buttons for some high-contrast VSCode themes
|
||||
- Offload expensive count token operations to a web worker (thanks @samhvw8)
|
||||
- Improve support for mult-root workspaces (thanks @snoyiatk)
|
||||
- Improve support for multi-root workspaces (thanks @snoyiatk)
|
||||
- Simplify and streamline Roo Code's quick actions
|
||||
- Allow Roo Code settings to be imported from the welcome screen (thanks @julionav)
|
||||
- Remove unused types (thanks @wkordalski)
|
||||
|
|
@ -2640,7 +2765,7 @@
|
|||
- Custom ARNs in Amazon Bedrock (thanks @Smartsheet-JB-Brown!)
|
||||
- Update MCP servers directory path for platform compatibility (thanks @hannesrudolph!)
|
||||
- Fix browser system prompt inclusion rules (thanks @cannuri!)
|
||||
- Publish git tags to github from CI (thanks @pdecat!)
|
||||
- Publish git tags to GitHub from CI (thanks @pdecat!)
|
||||
- Fixes to OpenAI-style cost calculations (thanks @dtrugman!)
|
||||
- Fix to allow using an excluded directory as your working directory (thanks @Szpadel!)
|
||||
- Kotlin language support in list_code_definition_names tool (thanks @kohii!)
|
||||
|
|
@ -2745,7 +2870,7 @@
|
|||
|
||||
## [3.7.6] - 2025-02-26
|
||||
|
||||
- Handle really long text better in the in the ChatRow similar to TaskHeader (thanks @joemanley201!)
|
||||
- Handle really long text better in the ChatRow similar to TaskHeader (thanks @joemanley201!)
|
||||
- Support multiple files in drag-and-drop
|
||||
- Truncate search_file output to avoid crashing the extension
|
||||
- Better OpenRouter error handling (no more "Provider Error")
|
||||
|
|
@ -2968,7 +3093,6 @@
|
|||
- Ask and Architect modes can now edit markdown files
|
||||
- Custom modes can now be restricted to specific file patterns (for example, a technical writer who can only edit markdown files 👋)
|
||||
- Support for configuring the Bedrock provider with AWS Profiles
|
||||
- New Roo Code community Discord at https://roocode.com/discord!
|
||||
|
||||
## [3.2.8]
|
||||
|
||||
|
|
@ -3008,8 +3132,6 @@
|
|||
- Create specialized assistants for any workflow
|
||||
- Just type "Create a new mode for <X>" or visit the Prompts tab in the top menu to get started
|
||||
|
||||
Join us at https://www.reddit.com/r/RooCode to share your custom modes and be part of our next chapter!
|
||||
|
||||
## [3.1.7]
|
||||
|
||||
- DeepSeek-R1 support (thanks @philipnext!)
|
||||
|
|
@ -3057,12 +3179,8 @@ Join us at https://www.reddit.com/r/RooCode to share your custom modes and be pa
|
|||
|
||||
## [3.0.1]
|
||||
|
||||
- Fix the reddit link and a small visual glitch in the chat input
|
||||
|
||||
## [3.0.0]
|
||||
|
||||
- This release adds chat modes! Now you can ask Roo Code questions about system architecture or the codebase without immediately jumping into writing code. You can even assign different API configuration profiles to each mode if you prefer to use different models for thinking vs coding. Would love feedback in the new Roo Code Reddit! https://www.reddit.com/r/RooCode
|
||||
|
||||
## [2.2.46]
|
||||
|
||||
- Only parse @-mentions in user input (not in files)
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
<div align="center">
|
||||
<sub>
|
||||
|
||||
<b>English</b> • [Català](locales/ca/CODE_OF_CONDUCT.md) • [Deutsch](locales/de/CODE_OF_CONDUCT.md) • [Español](locales/es/CODE_OF_CONDUCT.md) • [Français](locales/fr/CODE_OF_CONDUCT.md) • [हिंदी](locales/hi/CODE_OF_CONDUCT.md) • [Bahasa Indonesia](locales/id/CODE_OF_CONDUCT.md) • [Italiano](locales/it/CODE_OF_CONDUCT.md) • [日本語](locales/ja/CODE_OF_CONDUCT.md)
|
||||
|
||||
</sub>
|
||||
<sub>
|
||||
|
||||
[한국어](locales/ko/CODE_OF_CONDUCT.md) • [Nederlands](locales/nl/CODE_OF_CONDUCT.md) • [Polski](locales/pl/CODE_OF_CONDUCT.md) • [Português (BR)](locales/pt-BR/CODE_OF_CONDUCT.md) • [Русский](locales/ru/CODE_OF_CONDUCT.md) • [Türkçe](locales/tr/CODE_OF_CONDUCT.md) • [Tiếng Việt](locales/vi/CODE_OF_CONDUCT.md) • [简体中文](locales/zh-CN/CODE_OF_CONDUCT.md) • [繁體中文](locales/zh-TW/CODE_OF_CONDUCT.md)
|
||||
|
||||
</sub>
|
||||
</div>
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at support@roocode.com. All complaints
|
||||
will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from [Cline's version][cline_coc] of the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[cline_coc]: https://github.com/cline/cline/blob/main/CODE_OF_CONDUCT.md
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
141
CONTRIBUTING.md
141
CONTRIBUTING.md
|
|
@ -1,141 +0,0 @@
|
|||
<div align="center">
|
||||
<sub>
|
||||
|
||||
<b>English</b> • [Català](locales/ca/CONTRIBUTING.md) • [Deutsch](locales/de/CONTRIBUTING.md) • [Español](locales/es/CONTRIBUTING.md) • [Français](locales/fr/CONTRIBUTING.md) • [हिंदी](locales/hi/CONTRIBUTING.md) • [Bahasa Indonesia](locales/id/CONTRIBUTING.md) • [Italiano](locales/it/CONTRIBUTING.md) • [日本語](locales/ja/CONTRIBUTING.md)
|
||||
|
||||
</sub>
|
||||
<sub>
|
||||
|
||||
[한국어](locales/ko/CONTRIBUTING.md) • [Nederlands](locales/nl/CONTRIBUTING.md) • [Polski](locales/pl/CONTRIBUTING.md) • [Português (BR)](locales/pt-BR/CONTRIBUTING.md) • [Русский](locales/ru/CONTRIBUTING.md) • [Türkçe](locales/tr/CONTRIBUTING.md) • [Tiếng Việt](locales/vi/CONTRIBUTING.md) • [简体中文](locales/zh-CN/CONTRIBUTING.md) • [繁體中文](locales/zh-TW/CONTRIBUTING.md)
|
||||
|
||||
</sub>
|
||||
</div>
|
||||
|
||||
# Contributing to Roo Code
|
||||
|
||||
Roo Code is a community-driven project, and we deeply value every contribution. To streamline collaboration, we operate on an [Issue-First](#issue-first-approach) basis, meaning all [Pull Requests (PRs)](#submitting-a-pull-request) must first be linked to a GitHub Issue. Please review this guide carefully.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Before You Contribute](#before-you-contribute)
|
||||
- [Finding & Planning Your Contribution](#finding--planning-your-contribution)
|
||||
- [Development & Submission Process](#development--submission-process)
|
||||
- [Legal](#legal)
|
||||
|
||||
## Before You Contribute
|
||||
|
||||
### 1. Code of Conduct
|
||||
|
||||
All contributors must adhere to our [Code of Conduct](./CODE_OF_CONDUCT.md).
|
||||
|
||||
### 2. Project Roadmap
|
||||
|
||||
Our roadmap guides the project's direction. Align your contributions with these key goals:
|
||||
|
||||
### Reliability First
|
||||
|
||||
- Ensure diff editing and command execution are consistently reliable.
|
||||
- Reduce friction points that deter regular usage.
|
||||
- Guarantee smooth operation across all locales and platforms.
|
||||
- Expand robust support for a wide variety of AI providers and models.
|
||||
|
||||
### Enhanced User Experience
|
||||
|
||||
- Streamline the UI/UX for clarity and intuitiveness.
|
||||
- Continuously improve the workflow to meet the high expectations developers have for daily-use tools.
|
||||
|
||||
### Leading on Agent Performance
|
||||
|
||||
- Establish comprehensive evaluation benchmarks (evals) to measure real-world productivity.
|
||||
- Make it easy for everyone to easily run and interpret these evals.
|
||||
- Ship improvements that demonstrate clear increases in eval scores.
|
||||
|
||||
Mention alignment with these areas in your PRs.
|
||||
|
||||
### 3. Join the Roo Code Community
|
||||
|
||||
- **Primary:** Join our [Discord](https://discord.gg/roocode) and DM **Hannes Rudolph (`hrudolph`)**.
|
||||
- **Alternative:** Experienced contributors can engage directly via [GitHub Projects](https://github.com/orgs/RooCodeInc/projects/1).
|
||||
|
||||
## Finding & Planning Your Contribution
|
||||
|
||||
### Types of Contributions
|
||||
|
||||
- **Bug Fixes:** Addressing code issues.
|
||||
- **New Features:** Adding functionality.
|
||||
- **Documentation:** Improving guides and clarity.
|
||||
|
||||
### Issue-First Approach
|
||||
|
||||
All contributions start with a GitHub Issue using our skinny templates.
|
||||
|
||||
- **Check existing issues**: Search [GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues).
|
||||
- **Create an issue** using:
|
||||
- **Enhancements:** "Enhancement Request" template (plain language focused on user benefit).
|
||||
- **Bugs:** "Bug Report" template (minimal repro + expected vs actual + version).
|
||||
- **Want to work on it?** Comment "Claiming" on the issue and DM **Hannes Rudolph (`hrudolph`)** on [Discord](https://discord.gg/roocode) to get assigned. Assignment will be confirmed in the thread.
|
||||
- **PRs must link to the issue.** Unlinked PRs may be closed.
|
||||
|
||||
### Deciding What to Work On
|
||||
|
||||
- Check the [GitHub Project](https://github.com/orgs/RooCodeInc/projects/1) for "Issue [Unassigned]" issues.
|
||||
- For docs, visit [Roo Code Docs](https://github.com/RooCodeInc/Roo-Code-Docs).
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
- Check for existing reports first.
|
||||
- Create a new bug using the ["Bug Report" template](https://github.com/RooCodeInc/Roo-Code/issues/new/choose) with:
|
||||
- Clear, numbered reproduction steps
|
||||
- Expected vs actual result
|
||||
- Roo Code version (required); API provider/model if relevant
|
||||
- **Security issues**: Report privately via [security advisories](https://github.com/RooCodeInc/Roo-Code/security/advisories/new).
|
||||
|
||||
## Development & Submission Process
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Fork & Clone:**
|
||||
|
||||
```
|
||||
git clone https://github.com/YOUR_USERNAME/Roo-Code.git
|
||||
```
|
||||
|
||||
2. **Install Dependencies:**
|
||||
|
||||
```
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Debugging:** Open with VS Code (`F5`).
|
||||
|
||||
### Writing Code Guidelines
|
||||
|
||||
- One focused PR per feature or fix.
|
||||
- Follow ESLint and TypeScript best practices.
|
||||
- Write clear, descriptive commits referencing issues (e.g., `Fixes #123`).
|
||||
- Provide thorough testing (`npm test`).
|
||||
- Rebase onto the latest `main` branch before submission.
|
||||
|
||||
### Submitting a Pull Request
|
||||
|
||||
- Begin as a **Draft PR** if seeking early feedback.
|
||||
- Clearly describe your changes following the Pull Request Template.
|
||||
- Link the issue in the PR description/title (e.g., "Fixes #123").
|
||||
- Provide screenshots/videos for UI changes.
|
||||
- Indicate if documentation updates are necessary.
|
||||
|
||||
### Pull Request Policy
|
||||
|
||||
- Must reference an assigned GitHub Issue. To get assigned: comment "Claiming" on the issue and DM **Hannes Rudolph (`hrudolph`)** on [Discord](https://discord.gg/roocode). Assignment will be confirmed in the thread.
|
||||
- Unlinked PRs may be closed.
|
||||
- PRs should pass CI tests, align with the roadmap, and have clear documentation.
|
||||
|
||||
### Review Process
|
||||
|
||||
- **Daily Triage:** Quick checks by maintainers.
|
||||
- **Weekly In-depth Review:** Comprehensive assessment.
|
||||
- **Iterate promptly** based on feedback.
|
||||
|
||||
## Legal
|
||||
|
||||
By contributing, you agree your contributions will be licensed under the Apache 2.0 License, consistent with Roo Code's licensing.
|
||||
|
|
@ -6,12 +6,11 @@ Roo Code respects your privacy and is committed to transparency about how we han
|
|||
|
||||
### **Where Your Data Goes (And Where It Doesn’t)**
|
||||
|
||||
- **Code & Files**: Roo Code accesses files on your local machine when needed for AI-assisted features. When you send commands to Roo Code, relevant files may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. If you select Roo Code Cloud as the model provider (proxy mode), your code may transit Roo Code servers only to forward it to the upstream provider. We do not store your code; it is deleted immediately after forwarding. Otherwise, your code is sent directly to the provider. AI providers may store data per their privacy policies.
|
||||
- **Code & Files**: Roo Code accesses files on your local machine when needed for AI-assisted features. When you send commands to Roo Code, relevant files may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. AI providers may store data per their privacy policies.
|
||||
- **Commands**: Any commands executed through Roo Code happen on your local environment. However, when you use AI-powered features, the relevant code and context from your commands may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not have access to or store this data, but AI providers may process it per their privacy policies.
|
||||
- **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. If you choose Roo Code Cloud as the provider (proxy mode), prompts may transit Roo Code servers only to forward them to the upstream model and are not stored.
|
||||
- **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service.
|
||||
- **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen.
|
||||
- **Telemetry (Usage Data)**: We collect anonymous feature usage and error data to help us improve Roo Code. This telemetry is powered by PostHog and includes your VS Code machine ID, feature usage patterns, and exception reports. This telemetry does **not** collect personally identifiable information, your code, or AI prompts. You can opt out of this telemetry at any time through the settings.
|
||||
- **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Roo Code makes a secure API call to Roo Code's backend servers to retrieve listing information. These requests send only the query parameters (e.g., extension version, search term) necessary to fulfill the request and do not include your code, prompts, or personally identifiable information.
|
||||
|
||||
### **How We Use Your Data (If Collected)**
|
||||
|
||||
|
|
|
|||
119
README.md
119
README.md
|
|
@ -1,12 +1,5 @@
|
|||
<p align="center">
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline"><img src="https://img.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
|
||||
<a href="https://x.com/roocode"><img src="https://img.shields.io/badge/roocode-000000?style=flat&logo=x&logoColor=white" alt="X"></a>
|
||||
<a href="https://youtube.com/@roocodeyt?feature=shared"><img src="https://img.shields.io/badge/YouTube-FF0000?style=flat&logo=youtube&logoColor=white" alt="YouTube"></a>
|
||||
<a href="https://discord.gg/roocode"><img src="https://img.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Join Discord"></a>
|
||||
<a href="https://www.reddit.com/r/RooCode/"><img src="https://img.shields.io/badge/Join%20r%2FRooCode-FF4500?style=flat&logo=reddit&logoColor=white" alt="Join r/RooCode"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em>Get help fast → <a href="https://discord.gg/roocode">Join Discord</a> • Prefer async? → <a href="https://www.reddit.com/r/RooCode/">Join r/RooCode</a></em>
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline"><img src="https://img.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
|
||||
</p>
|
||||
|
||||
# Roo Code
|
||||
|
|
@ -35,7 +28,7 @@
|
|||
- [简体中文](locales/zh-CN/README.md)
|
||||
- [繁體中文](locales/zh-TW/README.md)
|
||||
- ...
|
||||
</details>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -58,119 +51,27 @@ Roo Code adapts to how you work:
|
|||
- Ask Mode: fast answers, explanations, and docs
|
||||
- Debug Mode: trace issues, add logs, isolate root causes
|
||||
- Custom Modes: build specialized modes for your team or workflow
|
||||
- Roomote Control: Roomote Control lets you remotely control tasks running in your local VS Code instance.
|
||||
|
||||
Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
|
||||
|
||||
## Tutorial & Feature Videos
|
||||
|
||||
<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> |
|
||||
|
||||
</div>
|
||||
<p align="center">
|
||||
<a href="https://docs.roocode.com/tutorial-videos">More quick tutorial and feature videos...</a>
|
||||
</p>
|
||||
Learn more: [Using Modes](https://roocodeinc.github.io/Roo-Code/basic-usage/using-modes) • [Custom Modes](https://roocodeinc.github.io/Roo-Code/advanced-usage/custom-modes)
|
||||
|
||||
## Resources
|
||||
|
||||
- **[Documentation](https://docs.roocode.com):** The official guide to installing, configuring, and mastering Roo Code.
|
||||
- **[YouTube Channel](https://youtube.com/@roocodeyt?feature=shared):** Watch tutorials and see features in action.
|
||||
- **[Discord Server](https://discord.gg/roocode):** Join the community for real-time help and discussion.
|
||||
- **[Reddit Community](https://www.reddit.com/r/RooCode):** Share your experiences and see what others are building.
|
||||
- **[Documentation](https://roocodeinc.github.io/Roo-Code/):** The official guide to installing, configuring, and mastering Roo Code.
|
||||
- **[GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues):** Report bugs and track development.
|
||||
- **[Feature Requests](https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop):** Have an idea? Share it with the developers.
|
||||
|
||||
---
|
||||
|
||||
## Local Setup & Development
|
||||
|
||||
1. **Clone** the repo:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/RooCodeInc/Roo-Code.git
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Run the extension**:
|
||||
|
||||
There are several ways to run the Roo Code extension:
|
||||
|
||||
### Development Mode (F5)
|
||||
|
||||
For active development, use VSCode's built-in debugging:
|
||||
|
||||
Press `F5` (or go to **Run** → **Start Debugging**) in VSCode. This will open a new VSCode window with the Roo Code extension running.
|
||||
|
||||
- Changes to the webview will appear immediately.
|
||||
- Changes to the core extension will also hot reload automatically.
|
||||
|
||||
### Automated VSIX Installation
|
||||
|
||||
To build and install the extension as a VSIX package directly into VSCode:
|
||||
|
||||
```sh
|
||||
pnpm install:vsix [-y] [--editor=<command>]
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- Ask which editor command to use (code/cursor/code-insiders) - defaults to 'code'
|
||||
- Uninstall any existing version of the extension.
|
||||
- Build the latest VSIX package.
|
||||
- Install the newly built VSIX.
|
||||
- Prompt you to restart VS Code for changes to take effect.
|
||||
|
||||
Options:
|
||||
|
||||
- `-y`: Skip all confirmation prompts and use defaults
|
||||
- `--editor=<command>`: Specify the editor command (e.g., `--editor=cursor` or `--editor=code-insiders`)
|
||||
|
||||
### Manual VSIX Installation
|
||||
|
||||
If you prefer to install the VSIX package manually:
|
||||
|
||||
1. First, build the VSIX package:
|
||||
```sh
|
||||
pnpm vsix
|
||||
```
|
||||
2. A `.vsix` file will be generated in the `bin/` directory (e.g., `bin/roo-cline-<version>.vsix`).
|
||||
3. Install it manually using the VSCode CLI:
|
||||
```sh
|
||||
code --install-extension bin/roo-cline-<version>.vsix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
We use [changesets](https://github.com/changesets/changesets) for versioning and publishing. Check our `CHANGELOG.md` for release notes.
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
The Roo Code Extension was shut down on May 15th.
|
||||
|
||||
- If you're looking for an alternative, check out [ZooCode](https://github.com/Zoo-Code-Org/Zoo-Code/) (a fork started by the Roo Code community) and [Cline](https://cline.bot/) (from where Roo Code originated).
|
||||
- If you were a paying user and have billing questions, please write [billing@roocode.com](mailto:billing@roocode.com).
|
||||
|
||||
**Please note** that Roo Code, Inc does **not** make any representations or warranties regarding any code, models, or other tools provided or made available in connection with Roo Code, any associated third-party tools, or any resulting outputs. You assume **all risks** associated with the use of any such tools or outputs; such tools are provided on an **"AS IS"** and **"AS AVAILABLE"** basis. Such risks may include, without limitation, intellectual property infringement, cyber vulnerabilities or attacks, bias, inaccuracies, errors, defects, viruses, downtime, property loss or damage, and/or personal injury. You are solely responsible for your use of any such tools or outputs (including, without limitation, the legality, appropriateness, and results thereof).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We love community contributions! Get started by reading our [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2025 Roo Code, Inc.](./LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can’t wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding!
|
||||
[Apache 2.0 © 2026 Roo Code, Inc.](./LICENSE)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,167 @@ 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
|
||||
|
|
|
|||
|
|
@ -41,27 +41,18 @@ 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
|
||||
rm -rf ~/.roo/cli ~/.local/bin/roo
|
||||
```
|
||||
|
||||
### Development Installation
|
||||
|
||||
For contributing or development:
|
||||
|
||||
```bash
|
||||
# From the monorepo root.
|
||||
pnpm install
|
||||
|
||||
# Build the main extension first.
|
||||
pnpm --filter roo-cline bundle
|
||||
|
||||
# Build the cli.
|
||||
pnpm --filter @roo-code/cli build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Mode (Default)
|
||||
|
|
@ -107,89 +98,46 @@ 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
|
||||
|
||||
### Roo Code Cloud Authentication
|
||||
|
||||
To use Roo Code Cloud features (like the provider proxy), you need to authenticate:
|
||||
|
||||
```bash
|
||||
# Log in to Roo Code Cloud (opens browser)
|
||||
roo auth login
|
||||
|
||||
# Check authentication status
|
||||
roo auth status
|
||||
|
||||
# Log out
|
||||
roo auth logout
|
||||
```
|
||||
|
||||
The `auth login` command:
|
||||
|
||||
1. Opens your browser to authenticate with Roo Code Cloud
|
||||
2. Receives a secure token via localhost callback
|
||||
3. Stores the token in `~/.config/roo/credentials.json`
|
||||
|
||||
Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when your token expires.
|
||||
|
||||
**Authentication Flow:**
|
||||
|
||||
```
|
||||
┌──────┐ ┌─────────┐ ┌───────────────┐
|
||||
│ CLI │ │ Browser │ │ Roo Code Cloud│
|
||||
└──┬───┘ └────┬────┘ └───────┬───────┘
|
||||
│ │ │
|
||||
│ Open auth URL │ │
|
||||
│─────────────────>│ │
|
||||
│ │ │
|
||||
│ │ Authenticate │
|
||||
│ │─────────────────────>│
|
||||
│ │ │
|
||||
│ │<─────────────────────│
|
||||
│ │ Token via callback │
|
||||
│<─────────────────│ │
|
||||
│ │ │
|
||||
│ Store token │ │
|
||||
│ │ │
|
||||
# Optional: provide taskId per start command
|
||||
printf '{"command":"start","requestId":"1","taskId":"018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `[prompt]` | Your prompt (positional argument, optional) | None |
|
||||
| `--prompt-file <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` |
|
||||
|
||||
## Auth Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ---------------------------------- |
|
||||
| `roo auth login` | Authenticate with Roo Code Cloud |
|
||||
| `roo auth logout` | Clear stored authentication token |
|
||||
| `roo auth status` | Show current authentication status |
|
||||
| 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 (anthropic, openai, openrouter, etc.) | `openrouter` |
|
||||
| `-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` |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
|
@ -197,19 +145,12 @@ The CLI will look for API keys in environment variables if not provided via `--a
|
|||
|
||||
| Provider | Environment Variable |
|
||||
| ----------------- | --------------------------- |
|
||||
| roo | `ROO_API_KEY` |
|
||||
| anthropic | `ANTHROPIC_API_KEY` |
|
||||
| openai-native | `OPENAI_API_KEY` |
|
||||
| openrouter | `OPENROUTER_API_KEY` |
|
||||
| gemini | `GOOGLE_API_KEY` |
|
||||
| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` |
|
||||
|
||||
**Authentication Environment Variables:**
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| `ROO_WEB_APP_URL` | Override the Roo Code Cloud URL (default: `https://app.roocode.com`) |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
@ -253,7 +194,7 @@ The CLI will look for API keys in environment variables if not provided via `--a
|
|||
|
||||
```bash
|
||||
# Run directly from source (no build required)
|
||||
pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello"
|
||||
pnpm dev --provider openrouter --api-key $OPENROUTER_API_KEY --print "Hello"
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
|
@ -265,12 +206,6 @@ pnpm check-types
|
|||
pnpm lint
|
||||
```
|
||||
|
||||
By default the `start` script points `ROO_CODE_PROVIDER_URL` at `http://localhost:8080/proxy` for local development. To point at the production API instead, override the environment variable:
|
||||
|
||||
```bash
|
||||
ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello"
|
||||
```
|
||||
|
||||
## Releasing
|
||||
|
||||
Official releases are created via the GitHub Actions workflow at `.github/workflows/cli-release.yml`.
|
||||
|
|
|
|||
|
|
@ -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.1.0",
|
||||
"version": "0.1.17",
|
||||
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
@ -13,11 +13,11 @@
|
|||
"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",
|
||||
"dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts",
|
||||
"dev:test-stdin": "tsx scripts/test-stdin-stream.ts",
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev:local": "tsx src/index.ts",
|
||||
"clean": "rimraf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"openrouter",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--workspace",
|
||||
workspacePath,
|
||||
"--create-with-session-id",
|
||||
sessionId,
|
||||
prompt,
|
||||
],
|
||||
{
|
||||
cwd: cliRoot,
|
||||
reject: false,
|
||||
},
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`create-with-session-id failed for ${sessionId} with exit code ${result.exitCode}: ${result.stderr || result.stdout}`,
|
||||
)
|
||||
}
|
||||
|
||||
const lines = result.stdout.split("\n")
|
||||
const events = lines.map(parseStreamEvent).filter((event): event is StreamEvent => Boolean(event))
|
||||
const errorEvent = events.find((event) => event.type === "error")
|
||||
|
||||
if (errorEvent) {
|
||||
throw new Error(
|
||||
`create-with-session-id emitted error for ${sessionId}: code=${errorEvent.code ?? "none"} content=${errorEvent.content ?? ""}`,
|
||||
)
|
||||
}
|
||||
|
||||
const completion = events.find((event) => event.type === "result" && event.done === true)
|
||||
if (!completion) {
|
||||
throw new Error(`create-with-session-id did not emit final result for ${sessionId}`)
|
||||
}
|
||||
|
||||
if (completion.success !== true) {
|
||||
throw new Error(`create-with-session-id completed unsuccessfully for ${sessionId}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeSessionAndSendMarker(
|
||||
cliRoot: string,
|
||||
workspacePath: string,
|
||||
sessionId: string,
|
||||
messageToken: string,
|
||||
): Promise<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",
|
||||
"openrouter",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--workspace",
|
||||
workspacePath,
|
||||
"--session-id",
|
||||
sessionId,
|
||||
],
|
||||
{
|
||||
cwd: cliRoot,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
reject: false,
|
||||
forceKillAfterDelay: 2_000,
|
||||
},
|
||||
)
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
process.stderr.write(chunk)
|
||||
})
|
||||
|
||||
let pingSent = false
|
||||
let messageSent = false
|
||||
let shutdownSent = false
|
||||
let sawMessageControlDone = false
|
||||
let sawUserTurnWithMarker = false
|
||||
let shutdownTaskId: string | undefined
|
||||
let handlerError: Error | null = null
|
||||
let timedOut = false
|
||||
|
||||
const sendCommand = (command: { command: "ping" | "message" | "shutdown"; requestId: string; prompt?: string }) => {
|
||||
if (!child.stdin || child.stdin.destroyed) {
|
||||
return
|
||||
}
|
||||
child.stdin.write(`${JSON.stringify(command)}\n`)
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
handlerError = new Error(
|
||||
`timed out resuming session ${sessionId} (pingSent=${pingSent}, messageSent=${messageSent}, sawMessageControlDone=${sawMessageControlDone}, sawUserTurnWithMarker=${sawUserTurnWithMarker})`,
|
||||
)
|
||||
child.kill("SIGTERM")
|
||||
}, RESUME_TIMEOUT_MS)
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout!,
|
||||
crlfDelay: Infinity,
|
||||
})
|
||||
|
||||
rl.on("line", (line) => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
|
||||
const event = parseStreamEvent(line)
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "system" && event.subtype === "init" && !pingSent) {
|
||||
pingSent = true
|
||||
sendCommand({ command: "ping", requestId: pingRequestId })
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "control" &&
|
||||
event.subtype === "done" &&
|
||||
event.command === "ping" &&
|
||||
event.requestId === pingRequestId &&
|
||||
!messageSent
|
||||
) {
|
||||
messageSent = true
|
||||
sendCommand({
|
||||
command: "message",
|
||||
requestId: messageRequestId,
|
||||
prompt: messagePrompt,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "control" &&
|
||||
event.subtype === "error" &&
|
||||
event.command === "message" &&
|
||||
event.requestId === messageRequestId
|
||||
) {
|
||||
handlerError = new Error(
|
||||
`message command failed while resuming ${sessionId}: code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
|
||||
)
|
||||
child.kill("SIGTERM")
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "control" &&
|
||||
event.subtype === "done" &&
|
||||
event.command === "message" &&
|
||||
event.requestId === messageRequestId
|
||||
) {
|
||||
sawMessageControlDone = true
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "user" && event.requestId === messageRequestId && event.content?.includes(messageToken)) {
|
||||
sawUserTurnWithMarker = true
|
||||
|
||||
if (!shutdownSent) {
|
||||
shutdownSent = true
|
||||
sendCommand({ command: "shutdown", requestId: shutdownRequestId })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "control" &&
|
||||
(event.subtype === "ack" || event.subtype === "done") &&
|
||||
event.command === "shutdown" &&
|
||||
event.requestId === shutdownRequestId &&
|
||||
typeof event.taskId === "string"
|
||||
) {
|
||||
shutdownTaskId = event.taskId
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "control" && event.subtype === "error" && event.requestId !== shutdownRequestId) {
|
||||
handlerError = new Error(
|
||||
`unexpected control error while resuming ${sessionId}: command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
|
||||
)
|
||||
child.kill("SIGTERM")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const result = await child
|
||||
clearTimeout(timeout)
|
||||
rl.close()
|
||||
|
||||
if (handlerError) {
|
||||
throw handlerError
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
throw new Error(`stream resume for ${sessionId} timed out`)
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`stream resume for ${sessionId} exited non-zero: ${result.exitCode}`)
|
||||
}
|
||||
|
||||
if (!sawMessageControlDone) {
|
||||
throw new Error(`did not observe message control completion while resuming ${sessionId}`)
|
||||
}
|
||||
|
||||
if (!sawUserTurnWithMarker) {
|
||||
throw new Error(`did not observe resumed user marker turn while resuming ${sessionId}`)
|
||||
}
|
||||
|
||||
if (shutdownTaskId !== sessionId) {
|
||||
throw new Error(
|
||||
`shutdown taskId did not match resumed session (expected=${sessionId}, actual=${shutdownTaskId ?? "none"})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cliRoot = process.env.ROO_CLI_ROOT
|
||||
? path.resolve(process.env.ROO_CLI_ROOT)
|
||||
: path.resolve(__dirname, "../../..")
|
||||
const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-cli-create-session-id-"))
|
||||
|
||||
const firstSessionId = randomUUID()
|
||||
const secondSessionId = randomUUID()
|
||||
const firstMarker = `FIRST-MARKER-${Date.now()}`
|
||||
const secondMarker = `SECOND-MARKER-${Date.now()}`
|
||||
|
||||
try {
|
||||
await createSessionWithCustomId(
|
||||
cliRoot,
|
||||
workspacePath,
|
||||
firstSessionId,
|
||||
`Create first session marker ${firstMarker}. Reply with exactly "ok-${firstMarker}".`,
|
||||
)
|
||||
await createSessionWithCustomId(
|
||||
cliRoot,
|
||||
workspacePath,
|
||||
secondSessionId,
|
||||
`Create second session marker ${secondMarker}. Reply with exactly "ok-${secondMarker}".`,
|
||||
)
|
||||
|
||||
const initialSessions = await listSessions(cliRoot, workspacePath)
|
||||
if (!initialSessions.some((session) => session.id === firstSessionId)) {
|
||||
throw new Error(`session list missing first custom session id ${firstSessionId}`)
|
||||
}
|
||||
if (!initialSessions.some((session) => session.id === secondSessionId)) {
|
||||
throw new Error(`session list missing second custom session id ${secondSessionId}`)
|
||||
}
|
||||
|
||||
const resumeMarkerForFirst = `resume-first-${Date.now()}`
|
||||
await resumeSessionAndSendMarker(cliRoot, workspacePath, firstSessionId, resumeMarkerForFirst)
|
||||
|
||||
const resumeMarkerForSecond = `resume-second-${Date.now()}`
|
||||
await resumeSessionAndSendMarker(cliRoot, workspacePath, secondSessionId, resumeMarkerForSecond)
|
||||
|
||||
console.log(`[PASS] created and resumed custom sessions: ${firstSessionId}, ${secondSessionId}`)
|
||||
} finally {
|
||||
await fs.rm(workspacePath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
})
|
||||
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", "openrouter", "--output-format", "stream-json"],
|
||||
{
|
||||
cwd: cliRoot,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
reject: false,
|
||||
forceKillAfterDelay: 2_000,
|
||||
},
|
||||
)
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
process.stderr.write(chunk)
|
||||
})
|
||||
|
||||
let requestCounter = 0
|
||||
|
||||
const context: StreamCaseContext = {
|
||||
cliRoot,
|
||||
timeoutMs,
|
||||
nextRequestId(prefix: string): string {
|
||||
requestCounter += 1
|
||||
return `${prefix}-${Date.now()}-${requestCounter}`
|
||||
},
|
||||
sendCommand(command: StreamCommand): void {
|
||||
if (child.stdin?.destroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
child.stdin.write(`${JSON.stringify(command)}\n`)
|
||||
},
|
||||
}
|
||||
|
||||
let handlerError: Error | null = null
|
||||
let timedOut = false
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
const message = options.onTimeoutMessage?.(context) ?? "timed out waiting for stream scenario completion"
|
||||
handlerError = new Error(message)
|
||||
child.kill("SIGTERM")
|
||||
}, timeoutMs)
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: child.stdout!,
|
||||
crlfDelay: Infinity,
|
||||
})
|
||||
|
||||
rl.on("line", (line) => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
|
||||
const event = parseEvent(line)
|
||||
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
options.onEvent(event, context)
|
||||
} catch (error) {
|
||||
handlerError = error instanceof Error ? error : new Error(String(error))
|
||||
child.kill("SIGTERM")
|
||||
}
|
||||
})
|
||||
|
||||
const result = await child
|
||||
clearTimeout(timeout)
|
||||
rl.close()
|
||||
|
||||
if (handlerError) {
|
||||
throw handlerError
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
throw new Error("stream scenario timed out")
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`CLI exited with non-zero code: ${result.exitCode}`)
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import readline from "readline"
|
||||
|
||||
import { execa } from "execa"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const cliRoot = path.resolve(__dirname, "..")
|
||||
|
||||
async function main() {
|
||||
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.stdout?.on("data", (chunk) => process.stdout.write(chunk))
|
||||
child.stderr?.on("data", (chunk) => process.stderr.write(chunk))
|
||||
|
||||
console.log("[wrapper] Type a message and press Enter to send it.")
|
||||
console.log("[wrapper] Type /exit to close stdin and let the CLI finish.")
|
||||
|
||||
let requestCounter = 0
|
||||
let hasStartedTask = false
|
||||
|
||||
const sendCommand = (payload: Record<string, unknown>) => {
|
||||
if (child.stdin?.destroyed) {
|
||||
return
|
||||
}
|
||||
child.stdin?.write(JSON.stringify(payload) + "\n")
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: true,
|
||||
})
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (line.trim() === "/exit") {
|
||||
console.log("[wrapper] Closing stdin...")
|
||||
sendCommand({
|
||||
command: "shutdown",
|
||||
requestId: `shutdown-${Date.now()}-${++requestCounter}`,
|
||||
})
|
||||
child.stdin?.end()
|
||||
rl.close()
|
||||
return
|
||||
}
|
||||
|
||||
const command = hasStartedTask ? "message" : "start"
|
||||
sendCommand({
|
||||
command,
|
||||
requestId: `${command}-${Date.now()}-${++requestCounter}`,
|
||||
prompt: line,
|
||||
})
|
||||
hasStartedTask = true
|
||||
})
|
||||
|
||||
const onSignal = (signal: NodeJS.Signals) => {
|
||||
console.log(`[wrapper] Received ${signal}, forwarding to CLI...`)
|
||||
rl.close()
|
||||
child.kill(signal)
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => onSignal("SIGINT"))
|
||||
process.on("SIGTERM", () => onSignal("SIGTERM"))
|
||||
|
||||
const result = await child
|
||||
rl.close()
|
||||
console.log(`[wrapper] CLI exited with code ${result.exitCode}`)
|
||||
process.exit(result.exitCode ?? 1)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[wrapper] Fatal error:", 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 })
|
||||
|
||||
|
|
|
|||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
|
@ -429,7 +446,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
|
|||
// Apply CLI settings to the runtime config and context proxy BEFORE
|
||||
// sending webviewDidLaunch. This prevents a race condition where the
|
||||
// webviewDidLaunch handler's first-time init sync reads default state
|
||||
// (apiProvider: "anthropic") instead of the CLI-provided settings.
|
||||
// instead of the CLI-provided settings.
|
||||
setRuntimeConfigValues("roo-cline", this.initialSettings as Record<string, unknown>)
|
||||
this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings })
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,12 +90,15 @@ 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
|
||||
|
|
@ -104,6 +107,18 @@ export class JsonEventEmitter {
|
|||
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.
|
||||
|
|
@ -153,7 +168,7 @@ export class JsonEventEmitter {
|
|||
emitControl(event: {
|
||||
subtype: "ack" | "done" | "error"
|
||||
requestId?: string
|
||||
command?: string
|
||||
command?: JsonEvent["command"]
|
||||
taskId?: string
|
||||
content?: string
|
||||
success?: boolean
|
||||
|
|
@ -224,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.
|
||||
*/
|
||||
|
|
@ -231,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).
|
||||
*/
|
||||
|
|
@ -238,6 +439,7 @@ export class JsonEventEmitter {
|
|||
if (this.mode === "stream-json" && isPartial) {
|
||||
return this.computeDelta(msgId, text)
|
||||
}
|
||||
|
||||
return text ?? null
|
||||
}
|
||||
|
||||
|
|
@ -252,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
|
||||
}
|
||||
|
||||
|
|
@ -283,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,10 +536,7 @@ 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":
|
||||
|
|
@ -398,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
|
||||
|
|
@ -445,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 || this.lastAssistantText
|
||||
// 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",
|
||||
|
|
@ -467,6 +791,10 @@ 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)
|
||||
|
|
@ -504,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")
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -519,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])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -537,6 +889,15 @@ 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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./login.js"
|
||||
export * from "./logout.js"
|
||||
export * from "./status.js"
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
import http from "http"
|
||||
import { randomBytes } from "crypto"
|
||||
import net from "net"
|
||||
import { exec } from "child_process"
|
||||
|
||||
import { AUTH_BASE_URL } from "@/types/index.js"
|
||||
import { saveToken } from "@/lib/storage/index.js"
|
||||
|
||||
export interface LoginOptions {
|
||||
timeout?: number
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| {
|
||||
success: true
|
||||
token: string
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
}
|
||||
|
||||
const LOCALHOST = "127.0.0.1"
|
||||
|
||||
export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginOptions = {}): Promise<LoginResult> {
|
||||
const state = randomBytes(16).toString("hex")
|
||||
const port = await getAvailablePort()
|
||||
const host = `http://${LOCALHOST}:${port}`
|
||||
|
||||
if (verbose) {
|
||||
console.log(`[Auth] Starting local callback server on port ${port}`)
|
||||
}
|
||||
|
||||
// Create promise that will be resolved when we receive the callback.
|
||||
const tokenPromise = new Promise<{ token: string; state: string }>((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url!, host)
|
||||
|
||||
if (url.pathname === "/callback") {
|
||||
const receivedState = url.searchParams.get("state")
|
||||
const token = url.searchParams.get("token")
|
||||
const error = url.searchParams.get("error")
|
||||
|
||||
if (error) {
|
||||
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=error-in-callback`)
|
||||
errorUrl.searchParams.set("message", error)
|
||||
res.writeHead(302, { Location: errorUrl.toString() })
|
||||
res.end(() => {
|
||||
server.close()
|
||||
reject(new Error(error))
|
||||
})
|
||||
} else if (!token) {
|
||||
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=missing-token`)
|
||||
errorUrl.searchParams.set("message", "Missing token in callback")
|
||||
res.writeHead(302, { Location: errorUrl.toString() })
|
||||
res.end(() => {
|
||||
server.close()
|
||||
reject(new Error("Missing token in callback"))
|
||||
})
|
||||
} else if (receivedState !== state) {
|
||||
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=invalid-state-parameter`)
|
||||
errorUrl.searchParams.set("message", "Invalid state parameter")
|
||||
res.writeHead(302, { Location: errorUrl.toString() })
|
||||
res.end(() => {
|
||||
server.close()
|
||||
reject(new Error("Invalid state parameter"))
|
||||
})
|
||||
} else {
|
||||
res.writeHead(302, { Location: `${AUTH_BASE_URL}/cli/sign-in?success=true` })
|
||||
res.end(() => {
|
||||
server.close()
|
||||
resolve({ token, state: receivedState })
|
||||
})
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" })
|
||||
res.end("Not found")
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(port, LOCALHOST)
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
server.close()
|
||||
reject(new Error("Authentication timed out"))
|
||||
}, timeout)
|
||||
|
||||
server.on("close", () => {
|
||||
clearTimeout(timeoutId)
|
||||
})
|
||||
})
|
||||
|
||||
const authUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in`)
|
||||
authUrl.searchParams.set("state", state)
|
||||
authUrl.searchParams.set("callback", `${host}/callback`)
|
||||
|
||||
console.log("Opening browser for authentication...")
|
||||
console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`)
|
||||
|
||||
try {
|
||||
await openBrowser(authUrl.toString())
|
||||
} catch (error) {
|
||||
if (verbose) {
|
||||
console.warn("[Auth] Failed to open browser automatically:", error)
|
||||
}
|
||||
|
||||
console.log("Please open the URL above in your browser manually.")
|
||||
}
|
||||
|
||||
try {
|
||||
const { token } = await tokenPromise
|
||||
await saveToken(token)
|
||||
console.log("✓ Successfully authenticated!")
|
||||
return { success: true, token }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`✗ Authentication failed: ${message}`)
|
||||
return { success: false, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailablePort(startPort = 49152, endPort = 65535): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
let port = startPort
|
||||
|
||||
const tryPort = () => {
|
||||
server.once("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE" && port < endPort) {
|
||||
port++
|
||||
tryPort()
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
server.once("listening", () => {
|
||||
server.close(() => {
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(port, LOCALHOST)
|
||||
}
|
||||
|
||||
tryPort()
|
||||
})
|
||||
}
|
||||
|
||||
function openBrowser(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const platform = process.platform
|
||||
let command: string
|
||||
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
command = `open "${url}"`
|
||||
break
|
||||
case "win32":
|
||||
command = `start "" "${url}"`
|
||||
break
|
||||
default:
|
||||
// Linux and other Unix-like systems.
|
||||
command = `xdg-open "${url}"`
|
||||
break
|
||||
}
|
||||
|
||||
exec(command, (error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { clearToken, hasToken, getCredentialsPath } from "@/lib/storage/index.js"
|
||||
|
||||
export interface LogoutOptions {
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export interface LogoutResult {
|
||||
success: boolean
|
||||
wasLoggedIn: boolean
|
||||
}
|
||||
|
||||
export async function logout({ verbose = false }: LogoutOptions = {}): Promise<LogoutResult> {
|
||||
const wasLoggedIn = await hasToken()
|
||||
|
||||
if (!wasLoggedIn) {
|
||||
console.log("You are not currently logged in.")
|
||||
return { success: true, wasLoggedIn: false }
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(`[Auth] Removing credentials from ${getCredentialsPath()}`)
|
||||
}
|
||||
|
||||
await clearToken()
|
||||
console.log("✓ Successfully logged out")
|
||||
return { success: true, wasLoggedIn: true }
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { loadToken, loadCredentials, getCredentialsPath } from "@/lib/storage/index.js"
|
||||
import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "@/lib/auth/index.js"
|
||||
|
||||
export interface StatusOptions {
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export interface StatusResult {
|
||||
authenticated: boolean
|
||||
expired?: boolean
|
||||
expiringSoon?: boolean
|
||||
userId?: string
|
||||
orgId?: string | null
|
||||
expiresAt?: Date
|
||||
createdAt?: Date
|
||||
}
|
||||
|
||||
export async function status(options: StatusOptions = {}): Promise<StatusResult> {
|
||||
const { verbose = false } = options
|
||||
|
||||
const token = await loadToken()
|
||||
|
||||
if (!token) {
|
||||
console.log("✗ Not authenticated")
|
||||
console.log("")
|
||||
console.log("Run: roo auth login")
|
||||
return { authenticated: false }
|
||||
}
|
||||
|
||||
const expiresAt = getTokenExpirationDate(token)
|
||||
const expired = !isTokenValid(token)
|
||||
const expiringSoon = isTokenExpired(token, 24 * 60 * 60) && !expired
|
||||
|
||||
const credentials = await loadCredentials()
|
||||
const createdAt = credentials?.createdAt ? new Date(credentials.createdAt) : undefined
|
||||
|
||||
if (expired) {
|
||||
console.log("✗ Authentication token expired")
|
||||
console.log("")
|
||||
console.log("Run: roo auth login")
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
expired: true,
|
||||
expiresAt: expiresAt ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (expiringSoon) {
|
||||
console.log("⚠ Expires soon; refresh with `roo auth login`")
|
||||
} else {
|
||||
console.log("✓ Authenticated")
|
||||
}
|
||||
|
||||
if (expiresAt) {
|
||||
const remaining = getTimeRemaining(expiresAt)
|
||||
console.log(` Expires: ${formatDate(expiresAt)} (${remaining})`)
|
||||
}
|
||||
|
||||
if (createdAt && verbose) {
|
||||
console.log(` Created: ${formatDate(createdAt)}`)
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.log(` Credentials: ${getCredentialsPath()}`)
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
expired: false,
|
||||
expiringSoon,
|
||||
expiresAt: expiresAt ?? undefined,
|
||||
createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
|
||||
}
|
||||
|
||||
function getTimeRemaining(date: Date): string {
|
||||
const now = new Date()
|
||||
const diff = date.getTime() - now.getTime()
|
||||
|
||||
if (diff <= 0) {
|
||||
return "expired"
|
||||
}
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} day${days === 1 ? "" : "s"}`
|
||||
}
|
||||
|
||||
return `${hours} hour${hours === 1 ? "" : "s"}`
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,14 @@
|
|||
import { parseFormat } from "../list.js"
|
||||
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", () => {
|
||||
|
|
@ -27,3 +37,48 @@ describe("parseFormat", () => {
|
|||
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)"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { parseStdinStreamCommand } from "../stdin-stream.js"
|
||||
import { parseStdinStreamCommand, shouldSendMessageAsAskResponse } from "../stdin-stream.js"
|
||||
|
||||
describe("parseStdinStreamCommand", () => {
|
||||
describe("valid commands", () => {
|
||||
|
|
@ -10,6 +10,24 @@ describe("parseStdinStreamCommand", () => {
|
|||
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" }),
|
||||
|
|
@ -18,6 +36,40 @@ describe("parseStdinStreamCommand", () => {
|
|||
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" })
|
||||
|
|
@ -95,10 +147,101 @@ describe("parseStdinStreamCommand", () => {
|
|||
)
|
||||
})
|
||||
|
||||
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,2 +1,3 @@
|
|||
export * from "./run.js"
|
||||
export * from "./list.js"
|
||||
export * from "./upgrade.js"
|
||||
|
|
|
|||
|
|
@ -4,16 +4,18 @@ 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 { openRouterDefaultModelId } from "@roo-code/types"
|
||||
|
||||
import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js"
|
||||
import { loadToken } from "@/lib/storage/index.js"
|
||||
import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js"
|
||||
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
|
||||
import { getApiKeyFromEnv } from "@/lib/utils/provider.js"
|
||||
import { isRecord } from "@/lib/utils/guards.js"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000
|
||||
|
||||
type ListFormat = "json" | "text"
|
||||
|
|
@ -28,6 +30,8 @@ type BaseListOptions = {
|
|||
|
||||
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()
|
||||
|
|
@ -81,22 +85,39 @@ function outputModelsText(models: ModelRecord): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function createListHost(options: BaseListOptions): Promise<ExtensionHost> {
|
||||
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 apiKey = options.apiKey || getApiKeyFromEnv("openrouter")
|
||||
|
||||
const extensionHostOptions: ExtensionHostOptions = {
|
||||
mode: "code",
|
||||
reasoningEffort: undefined,
|
||||
user: null,
|
||||
provider: "roo",
|
||||
model: getProviderDefaultModelId("roo"),
|
||||
provider: "openrouter",
|
||||
model: openRouterDefaultModelId,
|
||||
apiKey,
|
||||
workspacePath,
|
||||
extensionPath,
|
||||
nonInteractive: true,
|
||||
ephemeral: true,
|
||||
ephemeral: hostOptions.ephemeral,
|
||||
debug: options.debug ?? false,
|
||||
exitOnComplete: true,
|
||||
exitOnError: false,
|
||||
|
|
@ -104,6 +125,7 @@ async function createListHost(options: BaseListOptions): Promise<ExtensionHost>
|
|||
}
|
||||
|
||||
const host = new ExtensionHost(extensionHostOptions)
|
||||
|
||||
await host.activate()
|
||||
|
||||
// Best effort wait; mode/commands requests can still succeed without this.
|
||||
|
|
@ -192,34 +214,24 @@ function requestModes(host: ExtensionHost): Promise<ModeLike[]> {
|
|||
})
|
||||
}
|
||||
|
||||
function requestRooModels(host: ExtensionHost): Promise<ModelRecord> {
|
||||
return requestFromExtension(host, "requestRooModels", (message) => {
|
||||
if (message.type !== "singleRouterModelFetchResponse") {
|
||||
function requestOpenRouterModels(host: ExtensionHost): Promise<ModelRecord> {
|
||||
return requestFromExtension(host, "requestRouterModels", (message) => {
|
||||
if (message.type !== "routerModels") {
|
||||
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) : {}
|
||||
const routerModels = isRecord(message.routerModels) ? message.routerModels : {}
|
||||
const openRouterModels = routerModels.openrouter
|
||||
return isRecord(openRouterModels) ? (openRouterModels as ModelRecord) : {}
|
||||
})
|
||||
}
|
||||
|
||||
async function withHostAndSignalHandlers<T>(
|
||||
options: BaseListOptions,
|
||||
hostOptions: ListHostOptions,
|
||||
fn: (host: ExtensionHost) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const host = await createListHost(options)
|
||||
const host = await createListHost(options, hostOptions)
|
||||
|
||||
const shutdown = async (exitCode: number) => {
|
||||
await host.dispose()
|
||||
|
|
@ -244,7 +256,7 @@ async function withHostAndSignalHandlers<T>(
|
|||
export async function listCommands(options: BaseListOptions): Promise<void> {
|
||||
const format = parseFormat(options.format)
|
||||
|
||||
await withHostAndSignalHandlers(options, async (host) => {
|
||||
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
|
||||
const commands = await requestCommands(host)
|
||||
|
||||
if (format === "json") {
|
||||
|
|
@ -259,7 +271,7 @@ export async function listCommands(options: BaseListOptions): Promise<void> {
|
|||
export async function listModes(options: BaseListOptions): Promise<void> {
|
||||
const format = parseFormat(options.format)
|
||||
|
||||
await withHostAndSignalHandlers(options, async (host) => {
|
||||
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
|
||||
const modes = await requestModes(host)
|
||||
|
||||
if (format === "json") {
|
||||
|
|
@ -274,8 +286,8 @@ export async function listModes(options: BaseListOptions): Promise<void> {
|
|||
export async function listModels(options: BaseListOptions): Promise<void> {
|
||||
const format = parseFormat(options.format)
|
||||
|
||||
await withHostAndSignalHandlers(options, async (host) => {
|
||||
const models = await requestRooModels(host)
|
||||
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
|
||||
const models = await requestOpenRouterModels(host)
|
||||
|
||||
if (format === "json") {
|
||||
outputJson({ models })
|
||||
|
|
@ -285,3 +297,16 @@ export async function listModels(options: BaseListOptions): Promise<void> {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,33 +3,50 @@ import path from "path"
|
|||
import { fileURLToPath } from "url"
|
||||
|
||||
import { createElement } from "react"
|
||||
import pWaitFor from "p-wait-for"
|
||||
|
||||
import { setLogger } from "@roo-code/vscode-shim"
|
||||
|
||||
import {
|
||||
FlagOptions,
|
||||
isSupportedProvider,
|
||||
OnboardingProviderChoice,
|
||||
supportedProviders,
|
||||
DEFAULT_FLAGS,
|
||||
REASONING_EFFORTS,
|
||||
SDK_BASE_URL,
|
||||
OutputFormat,
|
||||
} from "@/types/index.js"
|
||||
import { isValidOutputFormat } from "@/types/json-events.js"
|
||||
import { JsonEventEmitter } from "@/agent/json-event-emitter.js"
|
||||
|
||||
import { createClient } from "@/lib/sdk/index.js"
|
||||
import { loadToken, loadSettings } from "@/lib/storage/index.js"
|
||||
import { loadSettings } from "@/lib/storage/index.js"
|
||||
import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/lib/task-history/index.js"
|
||||
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
|
||||
import { 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 SIGNAL_ONLY_EXIT_KEEPALIVE_MS = 60_000
|
||||
const STREAM_RESUME_WAIT_TIMEOUT_MS = 2_000
|
||||
|
||||
async function bootstrapResumeForStdinStream(host: ExtensionHost, sessionId: string): Promise<void> {
|
||||
host.sendToExtension({ type: "showTaskWithId", text: sessionId })
|
||||
|
||||
// Best-effort wait so early stdin "message" commands can target the resumed task.
|
||||
await pWaitFor(() => host.client.hasActiveTask() || host.isWaitingForInput(), {
|
||||
interval: 25,
|
||||
timeout: STREAM_RESUME_WAIT_TIMEOUT_MS,
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
export async function run(promptArg: string | undefined, flagOptions: FlagOptions) {
|
||||
setLogger({
|
||||
|
|
@ -50,31 +67,94 @@ 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()
|
||||
const settings = await loadSettings()
|
||||
|
||||
const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
|
||||
const isTuiEnabled = !flagOptions.print && isTuiSupported
|
||||
const isOnboardingEnabled = isTuiEnabled && !rooToken && !flagOptions.provider && !settings.provider
|
||||
|
||||
// Determine effective values: CLI flags > settings file > DEFAULT_FLAGS.
|
||||
const effectiveMode = flagOptions.mode || settings.mode || DEFAULT_FLAGS.mode
|
||||
const effectiveModel = flagOptions.model || settings.model || DEFAULT_FLAGS.model
|
||||
const effectiveReasoningEffort =
|
||||
flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort
|
||||
const effectiveProvider = flagOptions.provider ?? settings.provider ?? (rooToken ? "roo" : "openrouter")
|
||||
const effectiveProvider = flagOptions.provider ?? settings.provider ?? "openrouter"
|
||||
const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd()
|
||||
const legacyRequireApprovalFromSettings =
|
||||
settings.requireApproval ??
|
||||
(settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions)
|
||||
const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false
|
||||
const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false
|
||||
const rawConsecutiveMistakeLimit =
|
||||
flagOptions.consecutiveMistakeLimit ?? settings.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit
|
||||
const effectiveConsecutiveMistakeLimit = Number(rawConsecutiveMistakeLimit)
|
||||
|
||||
if (!Number.isInteger(effectiveConsecutiveMistakeLimit) || effectiveConsecutiveMistakeLimit < 0) {
|
||||
console.error(
|
||||
`[CLI] Error: Invalid consecutive mistake limit: ${rawConsecutiveMistakeLimit}; must be a non-negative integer`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let terminalShell: string | undefined
|
||||
if (flagOptions.terminalShell !== undefined) {
|
||||
const validatedTerminalShell = await validateTerminalShellPath(flagOptions.terminalShell)
|
||||
|
||||
if (!validatedTerminalShell.valid) {
|
||||
console.error(
|
||||
`[CLI] Warning: ignoring --terminal-shell "${flagOptions.terminalShell}" (${validatedTerminalShell.reason})`,
|
||||
)
|
||||
} else {
|
||||
terminalShell = validatedTerminalShell.shellPath
|
||||
}
|
||||
}
|
||||
|
||||
const extensionHostOptions: ExtensionHostOptions = {
|
||||
mode: effectiveMode,
|
||||
reasoningEffort: effectiveReasoningEffort === "unspecified" ? undefined : effectiveReasoningEffort,
|
||||
consecutiveMistakeLimit: effectiveConsecutiveMistakeLimit,
|
||||
user: null,
|
||||
provider: effectiveProvider,
|
||||
model: effectiveModel,
|
||||
|
|
@ -85,49 +165,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
ephemeral: flagOptions.ephemeral,
|
||||
debug: flagOptions.debug,
|
||||
exitOnComplete: effectiveExitOnComplete,
|
||||
}
|
||||
|
||||
// Roo Code Cloud Authentication
|
||||
|
||||
if (isOnboardingEnabled) {
|
||||
let { onboardingProviderChoice } = settings
|
||||
|
||||
if (!onboardingProviderChoice) {
|
||||
const { choice, token } = await runOnboarding()
|
||||
onboardingProviderChoice = choice
|
||||
rooToken = token ?? null
|
||||
}
|
||||
|
||||
if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
|
||||
extensionHostOptions.provider = "roo"
|
||||
}
|
||||
}
|
||||
|
||||
if (extensionHostOptions.provider === "roo") {
|
||||
if (rooToken) {
|
||||
try {
|
||||
const client = createClient({ url: SDK_BASE_URL, authToken: rooToken })
|
||||
const me = await client.auth.me.query()
|
||||
|
||||
if (me?.type !== "user") {
|
||||
throw new Error("Invalid token")
|
||||
}
|
||||
|
||||
extensionHostOptions.apiKey = rooToken
|
||||
extensionHostOptions.user = me.user
|
||||
} catch {
|
||||
// If an explicit API key was provided via flag or env var, fall through
|
||||
// to the general API key resolution below instead of exiting.
|
||||
if (!flagOptions.apiKey && !getApiKeyFromEnv(extensionHostOptions.provider)) {
|
||||
console.error("[CLI] Your Roo Code Router token is not valid.")
|
||||
console.error("[CLI] Please run: roo auth login")
|
||||
console.error("[CLI] Or use --api-key or set ROO_API_KEY to provide your own API key.")
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no rooToken, fall through to the general API key resolution below
|
||||
// which will check flagOptions.apiKey and ROO_API_KEY env var.
|
||||
terminalShell,
|
||||
}
|
||||
|
||||
// Validations
|
||||
|
|
@ -145,18 +183,8 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
extensionHostOptions.apiKey || flagOptions.apiKey || getApiKeyFromEnv(extensionHostOptions.provider)
|
||||
|
||||
if (!extensionHostOptions.apiKey) {
|
||||
if (extensionHostOptions.provider === "roo") {
|
||||
console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.")
|
||||
console.error("[CLI] Please run: roo auth login")
|
||||
console.error("[CLI] Or use --api-key to provide your own API key.")
|
||||
} else {
|
||||
console.error(
|
||||
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
|
||||
)
|
||||
console.error(
|
||||
`[CLI] For ${extensionHostOptions.provider}, set ${getEnvVarName(extensionHostOptions.provider)}`,
|
||||
)
|
||||
}
|
||||
console.error(`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`)
|
||||
console.error(`[CLI] For ${extensionHostOptions.provider}, set ${getEnvVarName(extensionHostOptions.provider)}`)
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
|
|
@ -196,6 +224,12 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
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]")
|
||||
|
|
@ -216,10 +250,28 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
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>")
|
||||
|
|
@ -251,6 +303,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),
|
||||
}),
|
||||
|
|
@ -268,11 +323,15 @@ 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({
|
||||
|
|
@ -281,17 +340,155 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
})
|
||||
: 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()
|
||||
|
|
@ -305,6 +502,10 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
throw new Error("--stdin-prompt-stream requires --output-format=stream-json to emit control events")
|
||||
}
|
||||
|
||||
if (isResumeRequested) {
|
||||
await bootstrapResumeForStdinStream(host, resolvedResumeSessionId!)
|
||||
}
|
||||
|
||||
await runStdinStreamMode({
|
||||
host,
|
||||
jsonEmitter,
|
||||
|
|
@ -313,28 +514,44 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
|
|||
},
|
||||
})
|
||||
} else {
|
||||
await host.runTask(prompt!)
|
||||
}
|
||||
|
||||
jsonEmitter?.detach()
|
||||
await host.dispose()
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
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 (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) {
|
||||
emitRuntimeError(normalizeError(error))
|
||||
await disposeHost()
|
||||
if (jsonEmitter) {
|
||||
await jsonEmitter.flush()
|
||||
}
|
||||
await flushStdout()
|
||||
|
||||
if (signalOnlyExit) {
|
||||
await parkUntilSignal("Task loop failed")
|
||||
}
|
||||
|
||||
process.off("SIGINT", onSigint)
|
||||
process.off("SIGTERM", onSigterm)
|
||||
process.off("uncaughtException", onUncaughtException)
|
||||
process.off("unhandledRejection", onUnhandledRejection)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
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"
|
||||
|
|
@ -9,20 +19,15 @@ import type { JsonEventEmitter } from "@/agent/json-event-emitter.js"
|
|||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type StdinStreamCommandName = "start" | "message" | "cancel" | "ping" | "shutdown"
|
||||
export type StdinStreamCommandName = RooCliCommandName
|
||||
|
||||
export type StdinStreamCommand =
|
||||
| { command: "start"; requestId: string; prompt: string }
|
||||
| { command: "message"; requestId: string; prompt: string }
|
||||
| { command: "cancel"; requestId: string }
|
||||
| { command: "ping"; requestId: string }
|
||||
| { command: "shutdown"; requestId: string }
|
||||
export type StdinStreamCommand = RooCliInputCommand
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VALID_STDIN_COMMANDS = new Set<StdinStreamCommandName>(["start", "message", "cancel", "ping", "shutdown"])
|
||||
export const VALID_STDIN_COMMANDS = new Set<StdinStreamCommandName>(rooCliCommandNames)
|
||||
|
||||
export function parseStdinStreamCommand(line: string, lineNumber: number): StdinStreamCommand {
|
||||
let parsed: unknown
|
||||
|
|
@ -59,11 +64,63 @@ export function parseStdinStreamCommand(line: string, lineNumber: number): Stdin
|
|||
|
||||
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"`)
|
||||
}
|
||||
|
||||
return { command, requestId, prompt: promptRaw }
|
||||
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 }
|
||||
|
|
@ -176,10 +233,110 @@ export interface StdinStreamModeOptions {
|
|||
setStreamRequestId: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
function isCancellationLikeError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const normalized = message.toLowerCase()
|
||||
return normalized.includes("aborted") || normalized.includes("cancelled") || normalized.includes("canceled")
|
||||
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) {
|
||||
|
|
@ -191,9 +348,47 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
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) {
|
||||
|
|
@ -208,8 +403,15 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
}
|
||||
|
||||
const offClientError = host.client.on("error", (error) => {
|
||||
if (cancelRequestedForActiveTask && isCancellationLikeError(error)) {
|
||||
if (activeTaskCommand === "start") {
|
||||
if (
|
||||
isExpectedControlFlowError(error, {
|
||||
stdinStreamMode: true,
|
||||
cancelRequested: cancelRequestedForActiveTask,
|
||||
shuttingDown: shouldShutdown,
|
||||
operation: "client",
|
||||
})
|
||||
) {
|
||||
if (activeTaskCommand === "start" && (cancelRequestedForActiveTask || isCancellationLikeError(error))) {
|
||||
jsonEmitter.emitControl({
|
||||
subtype: "done",
|
||||
requestId: activeRequestId,
|
||||
|
|
@ -224,6 +426,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
activeRequestId = undefined
|
||||
setStreamRequestId(undefined)
|
||||
cancelRequestedForActiveTask = false
|
||||
awaitingPostCancelRecovery = false
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -241,16 +444,61 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
|
||||
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?.currentTaskItem?.id
|
||||
const currentTaskId = message.state?.currentTaskId ?? message.state?.currentTaskItem?.id
|
||||
if (typeof currentTaskId === "string" && currentTaskId.trim().length > 0) {
|
||||
latestTaskId = currentTaskId
|
||||
}
|
||||
|
|
@ -264,6 +512,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
const queueMessageIds = queueSnapshot.map((item) => item.id)
|
||||
|
||||
if (!hasSeenQueueState) {
|
||||
assignRequestIdsToNewQueueMessages(queueMessageIds)
|
||||
hasSeenQueueState = true
|
||||
lastQueueDepth = queueDepth
|
||||
lastQueueMessageIds = queueMessageIds
|
||||
|
|
@ -289,6 +538,9 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
return
|
||||
}
|
||||
|
||||
promoteRequestIdForDequeuedMessages(queueMessageIds)
|
||||
assignRequestIdsToNewQueueMessages(queueMessageIds)
|
||||
|
||||
const subtype: "enqueued" | "dequeued" | "drained" | "updated" = depthChanged
|
||||
? queueDepth > lastQueueDepth
|
||||
? "enqueued"
|
||||
|
|
@ -337,9 +589,20 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
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
|
||||
setStreamRequestId(undefined)
|
||||
cancelRequestedForActiveTask = false
|
||||
}
|
||||
})
|
||||
|
|
@ -353,7 +616,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
}
|
||||
|
||||
switch (stdinCommand.command) {
|
||||
case "start":
|
||||
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
|
||||
|
|
@ -372,14 +635,16 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
code: "task_busy",
|
||||
success: false,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
activeRequestId = stdinCommand.requestId
|
||||
activeTaskCommand = "start"
|
||||
setStreamRequestId(stdinCommand.requestId)
|
||||
latestTaskId = undefined
|
||||
latestTaskId = stdinCommand.taskId ?? randomUUID()
|
||||
cancelRequestedForActiveTask = false
|
||||
awaitingPostCancelRecovery = false
|
||||
|
||||
jsonEmitter.emitControl({
|
||||
subtype: "ack",
|
||||
|
|
@ -391,13 +656,31 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
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)
|
||||
.runTask(stdinCommand.prompt, latestTaskId, taskConfiguration, stdinCommand.images)
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (cancelRequestedForActiveTask || isCancellationLikeError(error)) {
|
||||
if (activeTaskCommand === "start") {
|
||||
if (
|
||||
isExpectedControlFlowError(error, {
|
||||
stdinStreamMode: true,
|
||||
cancelRequested: cancelRequestedForActiveTask,
|
||||
shuttingDown: shouldShutdown,
|
||||
operation: "client",
|
||||
})
|
||||
) {
|
||||
if (
|
||||
activeTaskCommand === "start" &&
|
||||
(cancelRequestedForActiveTask || isCancellationLikeError(error))
|
||||
) {
|
||||
jsonEmitter.emitControl({
|
||||
subtype: "done",
|
||||
requestId: stdinCommand.requestId,
|
||||
|
|
@ -408,10 +691,12 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
success: false,
|
||||
})
|
||||
}
|
||||
|
||||
activeTaskCommand = undefined
|
||||
activeRequestId = undefined
|
||||
setStreamRequestId(undefined)
|
||||
cancelRequestedForActiveTask = false
|
||||
awaitingPostCancelRecovery = false
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -419,6 +704,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
activeTaskCommand = undefined
|
||||
activeRequestId = undefined
|
||||
setStreamRequestId(undefined)
|
||||
|
||||
jsonEmitter.emitControl({
|
||||
subtype: "error",
|
||||
requestId: stdinCommand.requestId,
|
||||
|
|
@ -432,9 +718,21 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
.finally(() => {
|
||||
activeTaskPromise = null
|
||||
})
|
||||
break
|
||||
|
||||
case "message":
|
||||
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",
|
||||
|
|
@ -445,10 +743,10 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
code: "no_active_task",
|
||||
success: false,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
setStreamRequestId(stdinCommand.requestId)
|
||||
jsonEmitter.emitControl({
|
||||
subtype: "ack",
|
||||
requestId: stdinCommand.requestId,
|
||||
|
|
@ -458,17 +756,53 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
code: "accepted",
|
||||
success: true,
|
||||
})
|
||||
host.sendToExtension({ type: "queueMessage", text: stdinCommand.prompt })
|
||||
|
||||
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: "message queued",
|
||||
code: "queued",
|
||||
content: wasResumable ? "resume message queued" : "message queued",
|
||||
code: wasResumable ? "resumed" : "queued",
|
||||
success: true,
|
||||
})
|
||||
|
||||
awaitingPostCancelRecovery = false
|
||||
break
|
||||
}
|
||||
|
||||
case "cancel": {
|
||||
setStreamRequestId(stdinCommand.requestId)
|
||||
|
|
@ -487,6 +821,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
code: "accepted",
|
||||
success: true,
|
||||
})
|
||||
|
||||
jsonEmitter.emitControl({
|
||||
subtype: "done",
|
||||
requestId: stdinCommand.requestId,
|
||||
|
|
@ -496,10 +831,13 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
code: "no_active_task",
|
||||
success: true,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
cancelRequestedForActiveTask = true
|
||||
awaitingPostCancelRecovery = true
|
||||
|
||||
jsonEmitter.emitControl({
|
||||
subtype: "ack",
|
||||
requestId: stdinCommand.requestId,
|
||||
|
|
@ -509,8 +847,10 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
code: "accepted",
|
||||
success: true,
|
||||
})
|
||||
|
||||
try {
|
||||
host.client.cancelTask()
|
||||
|
||||
jsonEmitter.emitControl({
|
||||
subtype: "done",
|
||||
requestId: stdinCommand.requestId,
|
||||
|
|
@ -521,7 +861,32 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
success: true,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isCancellationLikeError(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",
|
||||
|
|
@ -594,13 +959,15 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
|
|||
host.client.cancelTask()
|
||||
}
|
||||
|
||||
if (!shouldShutdown && host.client.hasActiveTask() && host.isWaitingForInput()) {
|
||||
const currentAsk = host.client.getCurrentAsk()
|
||||
throw new Error(`stdin ended while task was waiting for input (${currentAsk ?? "unknown"})`)
|
||||
}
|
||||
|
||||
if (!shouldShutdown && activeTaskPromise) {
|
||||
await activeTaskPromise
|
||||
if (!shouldShutdown) {
|
||||
if (activeTaskPromise) {
|
||||
await activeTaskPromise
|
||||
} else if (host.client.hasActiveTask()) {
|
||||
await waitForTaskProgressAfterStdinClosed(host, () => ({
|
||||
hasSeenQueueState,
|
||||
queueDepth: lastQueueDepth,
|
||||
}))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
offClientError()
|
||||
|
|
|
|||
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.")
|
||||
}
|
||||
|
|
@ -1,2 +1 @@
|
|||
export * from "./auth/index.js"
|
||||
export * from "./cli/index.js"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Command } from "commander"
|
|||
|
||||
import { DEFAULT_FLAGS } from "@/types/constants.js"
|
||||
import { VERSION } from "@/lib/utils/version.js"
|
||||
import { run, login, logout, status, listCommands, listModes, listModels } from "@/commands/index.js"
|
||||
import { run, listCommands, listModes, listModels, listSessions, upgrade } from "@/commands/index.js"
|
||||
|
||||
const program = new Command()
|
||||
|
||||
|
|
@ -10,10 +10,15 @@ 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(
|
||||
|
|
@ -21,18 +26,29 @@ program
|
|||
"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)
|
||||
.option("-k, --api-key <key>", "API key for the LLM provider")
|
||||
.option("--provider <provider>", "API provider (roo, anthropic, openai, openrouter, etc.)")
|
||||
.option("--provider <provider>", "API provider (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)
|
||||
|
|
@ -43,13 +59,17 @@ program
|
|||
)
|
||||
.action(run)
|
||||
|
||||
const listCommand = program.command("list").description("List commands, modes, or models")
|
||||
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("-k, --api-key <key>", "API key for the LLM provider")
|
||||
.option("--format <format>", 'Output format: "json" (default) or "text"', "json")
|
||||
.option("-d, --debug", "Enable debug output", false)
|
||||
|
||||
|
|
@ -64,6 +84,17 @@ const runListAction = async (action: () => Promise<void>) => {
|
|||
}
|
||||
}
|
||||
|
||||
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))
|
||||
|
|
@ -76,39 +107,23 @@ applyListOptions(listCommand.command("modes").description("List available modes"
|
|||
},
|
||||
)
|
||||
|
||||
applyListOptions(listCommand.command("models").description("List available Roo models")).action(
|
||||
applyListOptions(listCommand.command("models").description("List available models")).action(
|
||||
async (options: Parameters<typeof listModels>[0]) => {
|
||||
await runListAction(() => listModels(options))
|
||||
},
|
||||
)
|
||||
|
||||
const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
|
||||
applyListOptions(listCommand.command("sessions").description("List task sessions")).action(
|
||||
async (options: Parameters<typeof listSessions>[0]) => {
|
||||
await runListAction(() => listSessions(options))
|
||||
},
|
||||
)
|
||||
|
||||
authCommand
|
||||
.command("login")
|
||||
.description("Authenticate with Roo Code Cloud")
|
||||
.option("-v, --verbose", "Enable verbose output", false)
|
||||
.action(async (options: { verbose: boolean }) => {
|
||||
const result = await login({ verbose: options.verbose })
|
||||
process.exit(result.success ? 0 : 1)
|
||||
})
|
||||
|
||||
authCommand
|
||||
.command("logout")
|
||||
.description("Log out from Roo Code Cloud")
|
||||
.option("-v, --verbose", "Enable verbose output", false)
|
||||
.action(async (options: { verbose: boolean }) => {
|
||||
const result = await logout({ verbose: options.verbose })
|
||||
process.exit(result.success ? 0 : 1)
|
||||
})
|
||||
|
||||
authCommand
|
||||
.command("status")
|
||||
.description("Show authentication status")
|
||||
.option("-v, --verbose", "Enable verbose output", false)
|
||||
.action(async (options: { verbose: boolean }) => {
|
||||
const result = await status({ verbose: options.verbose })
|
||||
process.exit(result.authenticated ? 0 : 1)
|
||||
program
|
||||
.command("upgrade")
|
||||
.description("Upgrade Roo Code CLI to the latest version")
|
||||
.action(async () => {
|
||||
await runUpgradeAction(() => upgrade())
|
||||
})
|
||||
|
||||
program.parse()
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export * from "./token.js"
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
export interface DecodedToken {
|
||||
iss: string
|
||||
sub: string
|
||||
exp: number
|
||||
iat: number
|
||||
nbf: number
|
||||
v: number
|
||||
r?: {
|
||||
u?: string
|
||||
o?: string
|
||||
t: string
|
||||
}
|
||||
}
|
||||
|
||||
function decodeToken(token: string): DecodedToken | null {
|
||||
try {
|
||||
const parts = token.split(".")
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const payload = parts[1]
|
||||
|
||||
if (!payload) {
|
||||
return null
|
||||
}
|
||||
|
||||
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
|
||||
const decoded = Buffer.from(padded, "base64url").toString("utf-8")
|
||||
return JSON.parse(decoded) as DecodedToken
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenExpired(token: string, bufferSeconds = 24 * 60 * 60): boolean {
|
||||
const decoded = decodeToken(token)
|
||||
|
||||
if (!decoded?.exp) {
|
||||
return true
|
||||
}
|
||||
|
||||
const expiresAt = decoded.exp
|
||||
const bufferTime = Math.floor(Date.now() / 1000) + bufferSeconds
|
||||
return expiresAt < bufferTime
|
||||
}
|
||||
|
||||
export function isTokenValid(token: string): boolean {
|
||||
return !isTokenExpired(token, 0)
|
||||
}
|
||||
|
||||
export function getTokenExpirationDate(token: string): Date | null {
|
||||
const decoded = decodeToken(token)
|
||||
|
||||
if (!decoded?.exp) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Date(decoded.exp * 1000)
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
// Use vi.hoisted to make the test directory available to the mock
|
||||
// This must return the path synchronously since CREDENTIALS_FILE is computed at import time
|
||||
const { getTestConfigDir } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const os = require("os")
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const path = require("path")
|
||||
const testRunId = Date.now().toString()
|
||||
const testConfigDir = path.join(os.tmpdir(), `roo-cli-test-${testRunId}`)
|
||||
return { getTestConfigDir: () => testConfigDir }
|
||||
})
|
||||
|
||||
vi.mock("../config-dir.js", () => ({
|
||||
getConfigDir: getTestConfigDir,
|
||||
}))
|
||||
|
||||
// Import after mocking
|
||||
import { saveToken, loadToken, loadCredentials, clearToken, hasToken, getCredentialsPath } from "../credentials.js"
|
||||
|
||||
// Re-derive the test config dir for use in tests (must match the hoisted one)
|
||||
const actualTestConfigDir = getTestConfigDir()
|
||||
|
||||
describe("Token Storage", () => {
|
||||
const expectedCredentialsFile = path.join(actualTestConfigDir, "cli-credentials.json")
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear test directory before each test
|
||||
await fs.rm(actualTestConfigDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test directory
|
||||
await fs.rm(actualTestConfigDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("getCredentialsPath", () => {
|
||||
it("should return the correct credentials file path", () => {
|
||||
expect(getCredentialsPath()).toBe(expectedCredentialsFile)
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveToken", () => {
|
||||
it("should save token to disk", async () => {
|
||||
const token = "test-token-123"
|
||||
await saveToken(token)
|
||||
|
||||
const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
|
||||
const credentials = JSON.parse(savedData)
|
||||
|
||||
expect(credentials.token).toBe(token)
|
||||
expect(credentials.createdAt).toBeDefined()
|
||||
})
|
||||
|
||||
it("should save token with user info", async () => {
|
||||
const token = "test-token-456"
|
||||
await saveToken(token, { userId: "user_123", orgId: "org_456" })
|
||||
|
||||
const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
|
||||
const credentials = JSON.parse(savedData)
|
||||
|
||||
expect(credentials.token).toBe(token)
|
||||
expect(credentials.userId).toBe("user_123")
|
||||
expect(credentials.orgId).toBe("org_456")
|
||||
})
|
||||
|
||||
it("should create config directory if it doesn't exist", async () => {
|
||||
const token = "test-token-789"
|
||||
await saveToken(token)
|
||||
|
||||
const dirStats = await fs.stat(actualTestConfigDir)
|
||||
expect(dirStats.isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
// Unix file permissions don't apply on Windows - skip this test
|
||||
it.skipIf(process.platform === "win32")("should set restrictive file permissions", async () => {
|
||||
const token = "test-token-perms"
|
||||
await saveToken(token)
|
||||
|
||||
const stats = await fs.stat(expectedCredentialsFile)
|
||||
// Check that only owner has read/write (mode 0o600)
|
||||
const mode = stats.mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadToken", () => {
|
||||
it("should load saved token", async () => {
|
||||
const token = "test-token-abc"
|
||||
await saveToken(token)
|
||||
|
||||
const loaded = await loadToken()
|
||||
expect(loaded).toBe(token)
|
||||
})
|
||||
|
||||
it("should return null if no token exists", async () => {
|
||||
const loaded = await loadToken()
|
||||
expect(loaded).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadCredentials", () => {
|
||||
it("should load full credentials", async () => {
|
||||
const token = "test-token-def"
|
||||
await saveToken(token, { userId: "user_789" })
|
||||
|
||||
const credentials = await loadCredentials()
|
||||
|
||||
expect(credentials).not.toBeNull()
|
||||
expect(credentials?.token).toBe(token)
|
||||
expect(credentials?.userId).toBe("user_789")
|
||||
expect(credentials?.createdAt).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return null if no credentials exist", async () => {
|
||||
const credentials = await loadCredentials()
|
||||
expect(credentials).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearToken", () => {
|
||||
it("should remove saved token", async () => {
|
||||
const token = "test-token-ghi"
|
||||
await saveToken(token)
|
||||
|
||||
await clearToken()
|
||||
|
||||
const loaded = await loadToken()
|
||||
expect(loaded).toBeNull()
|
||||
})
|
||||
|
||||
it("should not throw if no token exists", async () => {
|
||||
await expect(clearToken()).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasToken", () => {
|
||||
it("should return true if token exists", async () => {
|
||||
await saveToken("test-token-jkl")
|
||||
|
||||
const exists = await hasToken()
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false if no token exists", async () => {
|
||||
const exists = await hasToken()
|
||||
expect(exists).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -51,7 +51,7 @@ describe("Settings Storage", () => {
|
|||
|
||||
it("should load saved settings", async () => {
|
||||
const settingsData = {
|
||||
onboardingProviderChoice: OnboardingProviderChoice.Roo,
|
||||
onboardingProviderChoice: OnboardingProviderChoice.Byok,
|
||||
mode: "architect",
|
||||
provider: "anthropic" as const,
|
||||
model: "claude-sonnet-4-20250514",
|
||||
|
|
@ -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 () => {
|
||||
|
|
@ -136,7 +138,7 @@ describe("Settings Storage", () => {
|
|||
|
||||
describe("resetOnboarding", () => {
|
||||
it("should reset onboarding provider choice", async () => {
|
||||
await saveSettings({ onboardingProviderChoice: OnboardingProviderChoice.Roo })
|
||||
await saveSettings({ onboardingProviderChoice: OnboardingProviderChoice.Byok })
|
||||
|
||||
await resetOnboarding()
|
||||
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
import { getConfigDir } from "./index.js"
|
||||
|
||||
const CREDENTIALS_FILE = path.join(getConfigDir(), "cli-credentials.json")
|
||||
|
||||
export interface Credentials {
|
||||
token: string
|
||||
createdAt: string
|
||||
userId?: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export async function saveToken(token: string, options?: { userId?: string; orgId?: string }): Promise<void> {
|
||||
await fs.mkdir(getConfigDir(), { recursive: true })
|
||||
|
||||
const credentials: Credentials = {
|
||||
token,
|
||||
createdAt: new Date().toISOString(),
|
||||
userId: options?.userId,
|
||||
orgId: options?.orgId,
|
||||
}
|
||||
|
||||
await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
|
||||
mode: 0o600, // Read/write for owner only
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadToken(): Promise<string | null> {
|
||||
try {
|
||||
const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
|
||||
const credentials: Credentials = JSON.parse(data)
|
||||
return credentials.token
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCredentials(): Promise<Credentials | null> {
|
||||
try {
|
||||
const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
|
||||
return JSON.parse(data) as Credentials
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearToken(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(CREDENTIALS_FILE)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasToken(): Promise<boolean> {
|
||||
const token = await loadToken()
|
||||
return token !== null
|
||||
}
|
||||
|
||||
export function getCredentialsPath(): string {
|
||||
return CREDENTIALS_FILE
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export * from "./config-dir.js"
|
||||
export * from "./settings.js"
|
||||
export * from "./credentials.js"
|
||||
export * from "./ephemeral.js"
|
||||
|
|
|
|||
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
|
||||
}
|
||||
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":
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import { createElement } from "react"
|
||||
|
||||
import { type OnboardingResult, OnboardingProviderChoice } from "@/types/index.js"
|
||||
import { login } from "@/commands/index.js"
|
||||
import { saveSettings } from "@/lib/storage/index.js"
|
||||
|
||||
export async function runOnboarding(): Promise<OnboardingResult> {
|
||||
const { render } = await import("ink")
|
||||
const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js")
|
||||
|
||||
return new Promise<OnboardingResult>((resolve) => {
|
||||
const onSelect = async (choice: OnboardingProviderChoice) => {
|
||||
await saveSettings({ onboardingProviderChoice: choice })
|
||||
|
||||
app.unmount()
|
||||
|
||||
console.log("")
|
||||
|
||||
if (choice === OnboardingProviderChoice.Roo) {
|
||||
const result = await login()
|
||||
await saveSettings({ onboardingProviderChoice: choice })
|
||||
|
||||
resolve({
|
||||
choice: OnboardingProviderChoice.Roo,
|
||||
token: result.success ? result.token : undefined,
|
||||
skipped: false,
|
||||
})
|
||||
} else {
|
||||
console.log("Using your own API key.")
|
||||
console.log("Set your API key via --api-key or environment variable.")
|
||||
console.log("")
|
||||
resolve({ choice: OnboardingProviderChoice.Byok, skipped: false })
|
||||
}
|
||||
}
|
||||
|
||||
const app = render(createElement(OnboardingScreen, { onSelect }))
|
||||
})
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ const envVarMap: Record<SupportedProvider, string> = {
|
|||
gemini: "GOOGLE_API_KEY",
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
|
||||
roo: "ROO_API_KEY",
|
||||
}
|
||||
|
||||
export function getEnvVarName(provider: SupportedProvider): string {
|
||||
|
|
@ -48,10 +47,6 @@ export function getProviderSettings(
|
|||
if (apiKey) config.vercelAiGatewayApiKey = apiKey
|
||||
if (model) config.vercelAiGatewayModelId = model
|
||||
break
|
||||
case "roo":
|
||||
if (apiKey) config.rooApiKey = apiKey
|
||||
if (model) config.apiModelId = model
|
||||
break
|
||||
default:
|
||||
if (apiKey) config.apiKey = apiKey
|
||||
if (model) config.apiModelId = model
|
||||
|
|
|
|||
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"]
|
||||
|
|
@ -20,7 +21,3 @@ export const ASCII_ROO = ` _,' ___
|
|||
\\,\\ / \\\\
|
||||
// \\\\
|
||||
,/' \`\\_,`
|
||||
|
||||
export const AUTH_BASE_URL = process.env.ROO_AUTH_BASE_URL ?? "https://app.roocode.com"
|
||||
|
||||
export const SDK_BASE_URL = process.env.ROO_SDK_BASE_URL ?? "https://cloud-api.roocode.com"
|
||||
|
|
|
|||
|
|
@ -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,66 +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)
|
||||
| "control" // Transport/control protocol events
|
||||
| "queue" // Message queue telemetry from extension state
|
||||
| "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 interface JsonEventQueueItem {
|
||||
/** Queue item id generated by MessageQueueService */
|
||||
id: string
|
||||
/** Queued text prompt preview */
|
||||
text?: string
|
||||
/** Number of attached images in the queued message */
|
||||
imageCount?: number
|
||||
/** Queue insertion/update timestamp (ms epoch) */
|
||||
timestamp?: number
|
||||
}
|
||||
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.
|
||||
|
|
@ -94,7 +64,7 @@ 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) */
|
||||
|
|
@ -137,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 */
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ export const supportedProviders = [
|
|||
"gemini",
|
||||
"openrouter",
|
||||
"vercel-ai-gateway",
|
||||
"roo",
|
||||
] as const satisfies ProviderName[]
|
||||
|
||||
export type SupportedProvider = (typeof supportedProviders)[number]
|
||||
|
|
@ -20,9 +19,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,14 +34,15 @@ export type FlagOptions = {
|
|||
provider?: SupportedProvider
|
||||
model?: string
|
||||
mode?: string
|
||||
terminalShell?: string
|
||||
reasoningEffort?: ReasoningEffortFlagOptions
|
||||
consecutiveMistakeLimit?: number
|
||||
ephemeral: boolean
|
||||
oneshot: boolean
|
||||
outputFormat?: OutputFormat
|
||||
}
|
||||
|
||||
export enum OnboardingProviderChoice {
|
||||
Roo = "roo",
|
||||
Byok = "byok",
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +62,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,28 +0,0 @@
|
|||
import { Box, Text } from "ink"
|
||||
import { Select } from "@inkjs/ui"
|
||||
|
||||
import { OnboardingProviderChoice, ASCII_ROO } from "@/types/index.js"
|
||||
|
||||
export interface OnboardingScreenProps {
|
||||
onSelect: (choice: OnboardingProviderChoice) => void
|
||||
}
|
||||
|
||||
export function OnboardingScreen({ onSelect }: OnboardingScreenProps) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text bold color="cyan">
|
||||
{ASCII_ROO}
|
||||
</Text>
|
||||
<Text dimColor>Welcome! How would you like to connect to an LLM provider?</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "Connect to Roo Code Cloud", value: OnboardingProviderChoice.Roo },
|
||||
{ label: "Bring your own API key", value: OnboardingProviderChoice.Byok },
|
||||
]}
|
||||
onChange={(value: string) => {
|
||||
onSelect(value as OnboardingProviderChoice)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "./OnboardingScreen.js"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
1
apps/docs/.env.example
Normal file
1
apps/docs/.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
# No environment variables are required for local docs development.
|
||||
31
apps/docs/.gitignore
vendored
Normal file
31
apps/docs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
*.js
|
||||
!src/**/*.js
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env
|
||||
!.env.example
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
.devcontainer
|
||||
TEMP/
|
||||
|
||||
.history/
|
||||
|
||||
.roo/mcp.json
|
||||
201
apps/docs/LICENSE
Normal file
201
apps/docs/LICENSE
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
21
apps/docs/README.md
Normal file
21
apps/docs/README.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Roo Code Docs
|
||||
|
||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator, and lives at https://roocodeinc.github.io/Roo-Code/
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ pnpm start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### License
|
||||
|
||||
[](LICENSE)
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
---
|
||||
description: Learn how the access_mcp_resource tool retrieves data from Model Context Protocol servers for additional context in Roo Code tasks.
|
||||
keywords:
|
||||
- access_mcp_resource
|
||||
- MCP
|
||||
- Model Context Protocol
|
||||
- MCP resources
|
||||
- Roo Code tools
|
||||
- context retrieval
|
||||
- API integration
|
||||
---
|
||||
|
||||
# access_mcp_resource
|
||||
|
||||
The `access_mcp_resource` tool retrieves data from resources exposed by connected Model Context Protocol (MCP) servers. It allows Roo to access files, API responses, documentation, or system information that provides additional context for tasks.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `server_name` (required): The name of the MCP server providing the resource
|
||||
- `uri` (required): The URI identifying the specific resource to access
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool connects to MCP servers and fetches data from their exposed resources. Unlike `use_mcp_tool` which executes actions, this tool specifically retrieves information that serves as context for tasks.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When Roo needs additional context from external systems
|
||||
- When Roo needs to access domain-specific data from specialized MCP servers
|
||||
- When Roo needs to retrieve reference documentation hosted by MCP servers
|
||||
- When Roo needs to integrate real-time data from external APIs via MCP
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Retrieves both text and image data from MCP resources
|
||||
- Requires user approval before executing resource access
|
||||
- Uses URI-based addressing to precisely identify resources
|
||||
- Integrates with the Model Context Protocol SDK
|
||||
- Displays resource content appropriately based on content type
|
||||
- Supports timeouts for reliable network operations
|
||||
- Handles server connection states (connected, connecting, disconnected)
|
||||
- Discovers available resources from connected servers
|
||||
- Processes structured response data with metadata
|
||||
- Handles image content special rendering
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Depends on external MCP servers being available and connected
|
||||
- Limited to the resources provided by connected servers
|
||||
- Cannot access resources from disabled servers
|
||||
- Network issues can affect reliability and performance
|
||||
- Resource access subject to configured timeouts
|
||||
- URI formats are determined by the specific MCP server implementation
|
||||
- No offline or cached resource access capabilities
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `access_mcp_resource` tool is invoked, it follows this process:
|
||||
|
||||
1. **Connection Validation**:
|
||||
|
||||
- Verifies that an MCP hub is available and initialized
|
||||
- Confirms the specified server exists in the connection list
|
||||
- Checks if the server is disabled (returns an error if it is)
|
||||
|
||||
2. **User Approval**:
|
||||
|
||||
- Presents the resource access request to the user for approval
|
||||
- Provides server name and resource URI for user verification
|
||||
- Proceeds only if the user approves the resource access
|
||||
|
||||
3. **Resource Request**:
|
||||
|
||||
- Uses the Model Context Protocol SDK to communicate with servers
|
||||
- Makes a `resources/read` request to the server through the MCP hub
|
||||
- Applies configured timeouts to prevent hanging on unresponsive servers
|
||||
|
||||
4. **Response Processing**:
|
||||
- Receives a structured response with metadata and content arrays
|
||||
- Processes text content for display to the user
|
||||
- Handles image data specially for appropriate display
|
||||
- Returns the processed resource data to Roo for use in the current task
|
||||
|
||||
---
|
||||
|
||||
## Resource Types
|
||||
|
||||
MCP servers can provide two main types of resources:
|
||||
|
||||
1. **Standard Resources**:
|
||||
|
||||
- Fixed resources with specific URIs
|
||||
- Defined name, description, and MIME type
|
||||
- Direct access without parameters
|
||||
- Typically represent static data or real-time information
|
||||
|
||||
2. **Resource Templates**:
|
||||
- Parameterized resources with placeholder values in URIs
|
||||
- Allow dynamic resource generation based on provided parameters
|
||||
- Can represent queries or filtered views of data
|
||||
- More flexible but require additional URI formatting
|
||||
|
||||
---
|
||||
|
||||
## Examples When Used
|
||||
|
||||
- When helping with API development, Roo retrieves endpoint specifications from MCP resources to ensure correct implementation.
|
||||
- When assisting with data visualization, Roo accesses current data samples from connected MCP servers.
|
||||
- When working in specialized domains, Roo retrieves technical documentation to provide accurate guidance.
|
||||
- When generating industry-specific code, Roo references compliance requirements from documentation resources.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Accessing current weather data:
|
||||
|
||||
```
|
||||
<access_mcp_resource>
|
||||
<server_name>weather-server</server_name>
|
||||
<uri>weather://san-francisco/current</uri>
|
||||
</access_mcp_resource>
|
||||
```
|
||||
|
||||
Retrieving API documentation:
|
||||
|
||||
```
|
||||
<access_mcp_resource>
|
||||
<server_name>api-docs</server_name>
|
||||
<uri>docs://payment-service/endpoints</uri>
|
||||
</access_mcp_resource>
|
||||
```
|
||||
|
||||
Accessing domain-specific knowledge:
|
||||
|
||||
```
|
||||
<access_mcp_resource>
|
||||
<server_name>knowledge-base</server_name>
|
||||
<uri>kb://medical/terminology/common</uri>
|
||||
</access_mcp_resource>
|
||||
```
|
||||
|
||||
Fetching system configuration:
|
||||
|
||||
```
|
||||
<access_mcp_resource>
|
||||
<server_name>infra-monitor</server_name>
|
||||
<uri>config://production/database</uri>
|
||||
</access_mcp_resource>
|
||||
```
|
||||
120
apps/docs/docs/advanced-usage/available-tools/apply-diff.md
Normal file
120
apps/docs/docs/advanced-usage/available-tools/apply-diff.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
description: Master the apply_diff tool for making surgical code changes using fuzzy matching and line hints in Roo Code with multi-file support.
|
||||
keywords:
|
||||
- apply_diff
|
||||
- file editing
|
||||
- code modifications
|
||||
- fuzzy matching
|
||||
- diff tool
|
||||
- Roo Code tools
|
||||
- multi-file edits
|
||||
---
|
||||
|
||||
# apply_diff
|
||||
|
||||
The `apply_diff` tool makes precise, surgical changes to files by specifying exactly what content to replace. It uses a sophisticated strategy for finding and applying changes while maintaining proper code formatting and structure.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `path` (required): The path of the file to modify relative to the current working directory.
|
||||
- `diff` (required): The search/replace block defining the changes using a format specific to the active diff strategy.
|
||||
- `start_line` (optional): A hint for where the search content begins. _Note: This top-level parameter appears unused by the current main strategy, which relies on `:start_line:` within the diff content._
|
||||
- `end_line` (optional): A hint for where the search content ends. _Note: This top-level parameter appears unused by the current main strategy._
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool applies targeted changes to existing files using fuzzy matching guided by line number hints to locate and replace content precisely. Unlike simple search and replace, it identifies the exact block for replacement based on the provided content and location hints.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When Roo needs to make precise changes to existing code without rewriting entire files.
|
||||
- When refactoring specific sections of code while maintaining surrounding context.
|
||||
- When fixing bugs in existing code with surgical precision.
|
||||
- When implementing feature enhancements that modify only certain parts of a file.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Uses fuzzy matching (Levenshtein distance on normalized strings) guided by a `:start_line:` hint, with configurable confidence thresholds (typically 0.8-1.0).
|
||||
- Provides context around matches using `BUFFER_LINES` (default 40).
|
||||
- Performs a middle-out search within a configurable context window (`bufferLines`) around the hinted start line.
|
||||
- Preserves code formatting and indentation passively by replacing exact blocks.
|
||||
- Shows changes in a diff view for user review and editing before applying.
|
||||
- Tracks consecutive errors per file (`consecutiveMistakeCountForApplyDiff`) to prevent repeated failures.
|
||||
- Validates file access against `.rooignore` rules.
|
||||
- Handles multi-line edits effectively.
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Works best with unique, distinctive code sections for reliable identification.
|
||||
- Performance can vary with very large files or highly repetitive code patterns.
|
||||
- Fuzzy matching might occasionally select incorrect locations if content is ambiguous.
|
||||
- Each diff strategy has specific format requirements.
|
||||
- Complex edits might require careful strategy selection or manual review.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `apply_diff` tool is invoked, it follows this process:
|
||||
|
||||
1. **Parameter Validation**: Validates required `path` and `diff` parameters.
|
||||
2. **RooIgnore Check**: Validates if the target file path is allowed by `.rooignore` rules.
|
||||
3. **File Analysis**: Loads the target file content.
|
||||
4. **Match Finding**: Uses a fuzzy matching algorithm (Levenshtein on normalized strings) guided by the `:start_line:` hint within a context window (`BUFFER_LINES`), searching middle-out to locate the target content based on the confidence threshold.
|
||||
5. **Change Preparation**: Generates the proposed changes by replacing the identified block.
|
||||
6. **User Interaction**:
|
||||
- Displays the changes in a diff view.
|
||||
- Allows the user to review and potentially edit the proposed changes.
|
||||
- Waits for user approval or rejection.
|
||||
7. **Change Application**: If approved, applies the changes (potentially including user edits) to the file.
|
||||
8. **Error Handling**: If errors occur (e.g., match failure, partial application), increments the `consecutiveMistakeCountForApplyDiff` for the file and reports the failure type.
|
||||
9. **Feedback**: Returns the result, including any user feedback or error details.
|
||||
|
||||
---
|
||||
|
||||
## Diff Format Requirements
|
||||
|
||||
The `<diff>` parameter requires a specific format supporting one or more changes in a single request. Each change block requires a line number hint for the original content.
|
||||
|
||||
- **Requires**: Exact match for the `SEARCH` block content (within the fuzzy threshold), including whitespace and indentation. The `:start_line:` number hint is mandatory within each block. The `:end_line:` hint is optional (but supported by the parser). Markers like `<<<<<<<` within the file's content must be escaped (`\\`) in the SEARCH block.
|
||||
|
||||
Example format for the `<diff>` block:
|
||||
|
||||
```diff
|
||||
<<<<<<< SEARCH
|
||||
:start_line:10
|
||||
:end_line:12
|
||||
-------
|
||||
// Old calculation logic
|
||||
const result = value * 0.9;
|
||||
return result;
|
||||
=======
|
||||
// Updated calculation logic with logging
|
||||
console.log(`Calculating for value: ${value}`);
|
||||
const result = value * 0.95; // Adjusted factor
|
||||
return result;
|
||||
>>>>>>> REPLACE
|
||||
|
||||
<<<<<<< SEARCH
|
||||
:start_line:25
|
||||
:end_line:25
|
||||
-------
|
||||
const defaultTimeout = 5000;
|
||||
=======
|
||||
const defaultTimeout = 10000; // Increased timeout
|
||||
>>>>>>> REPLACE
|
||||
```
|
||||
|
||||
---
|
||||
110
apps/docs/docs/advanced-usage/available-tools/apply-patch.md
Normal file
110
apps/docs/docs/advanced-usage/available-tools/apply-patch.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
description: Apply unified diff patches to multiple files in a single operation using the apply_patch tool in Roo Code.
|
||||
keywords:
|
||||
- apply_patch
|
||||
- patch
|
||||
- unified diff
|
||||
- multi-file edits
|
||||
- file operations
|
||||
- Roo Code tools
|
||||
- diff patches
|
||||
---
|
||||
|
||||
# apply_patch
|
||||
|
||||
The `apply_patch` tool applies unified diff patches to multiple files in a single operation. It supports custom patch headers for adding, deleting, and updating files, making it ideal for complex multi-file refactoring operations.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `patch` (required): A unified diff patch string with custom headers. Supports `*** Add File:`, `*** Delete File:`, and `*** Update File:` headers.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool processes unified diff patches containing operations for multiple files. It parses the patch content, identifies file operations (add, delete, update), and applies the changes atomically. Unlike [`apply_diff`](/advanced-usage/available-tools/apply-diff) which handles single-file search-and-replace operations, `apply_patch` works with traditional unified diff format.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When applying patches generated by version control systems or diff tools
|
||||
- When performing complex multi-file refactoring with precise line-level changes
|
||||
- When migrating code changes from one branch or repository to another
|
||||
- When bulk-adding, updating, or removing multiple files in one operation
|
||||
- When working with patches from external sources or automated tools
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Supports multiple files in a single patch operation
|
||||
- Handles file addition, deletion, and modification
|
||||
- Uses unified diff format for precise line-level control
|
||||
- Custom headers (`*** Add File:`, `*** Delete File:`, `*** Update File:`) for clarity
|
||||
- Atomic operations with validation before applying changes
|
||||
- Compatible with standard diff/patch tooling output
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires proper unified diff format syntax
|
||||
- Line numbers and context must match existing file content
|
||||
- Cannot apply patches with conflicts or mismatched context
|
||||
- Less flexible than search-and-replace tools for fuzzy matching
|
||||
- Requires exact line-level accuracy in patches
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `apply_patch` tool is invoked, it follows this process:
|
||||
|
||||
1. **Patch Parsing**: Parses the patch string to identify custom headers (`*** Add File:`, `*** Delete File:`, `*** Update File:`) and unified diff blocks.
|
||||
2. **Operation Identification**: Groups changes by file path and operation type (add, delete, update).
|
||||
3. **Validation**: Validates that target files exist (for updates/deletes) or can be created (for adds).
|
||||
4. **RooIgnore Check**: Ensures target files are not restricted by `.rooignore` rules.
|
||||
5. **User Review**: Presents the patch operations for user review and approval.
|
||||
6. **Application**: Applies approved changes to each file sequentially.
|
||||
7. **Feedback**: Reports success or failure for each file operation.
|
||||
|
||||
---
|
||||
|
||||
## Patch Format
|
||||
|
||||
The patch format uses custom headers followed by unified diff blocks:
|
||||
|
||||
```diff
|
||||
*** Add File: src/utils/newHelper.ts
|
||||
--- /dev/null
|
||||
+++ b/src/utils/newHelper.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
+export function helperFunction(value: string): string {
|
||||
+ return value.toUpperCase();
|
||||
+}
|
||||
|
||||
*** Update File: src/main.ts
|
||||
--- a/src/main.ts
|
||||
+++ b/src/main.ts
|
||||
@@ -10,7 +10,7 @@
|
||||
import { config } from './config';
|
||||
-const timeout = 5000;
|
||||
+const timeout = 10000;
|
||||
|
||||
function main() {
|
||||
|
||||
*** Delete File: src/deprecated/oldUtil.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relation to Other Tools
|
||||
|
||||
- [`apply_diff`](/advanced-usage/available-tools/apply-diff): Use for single-file search-and-replace with fuzzy matching
|
||||
- `apply_patch`: Use for multi-file operations with unified diff format
|
||||
- [`write_to_file`](/advanced-usage/available-tools/write-to-file): Use for creating entire new files
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
---
|
||||
description: Enable interactive communication in Roo Code with the ask_followup_question tool for gathering clarification and user preferences.
|
||||
keywords:
|
||||
- ask_followup_question
|
||||
- user interaction
|
||||
- interactive communication
|
||||
- Roo Code tools
|
||||
- clarification
|
||||
- user feedback
|
||||
---
|
||||
|
||||
# ask_followup_question
|
||||
|
||||
The `ask_followup_question` tool enables interactive communication by asking specific questions to gather additional information needed to complete tasks effectively.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `question` (required): The specific question to ask the user
|
||||
- `follow_up` (optional): A list of 2-4 suggested answers that help guide user responses, each within `<suggest>` tags
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool creates a conversational interface between Roo and the user, allowing for gathering clarification, additional details, or user preferences when facing ambiguities or decision points. Each question can include suggested responses to streamline the interaction.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When critical information is missing from the original request
|
||||
- When Roo needs to choose between multiple valid implementation approaches
|
||||
- When technical details or preferences are required to proceed
|
||||
- When Roo encounters ambiguities that need resolution
|
||||
- When additional context would significantly improve the solution quality
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Provides a structured way to gather specific information without breaking workflow
|
||||
- Includes suggested answers to reduce user typing and guide responses
|
||||
- Maintains conversation history and context across interactions
|
||||
- Supports responses containing images and code snippets
|
||||
- Available in all modes as part of the "always available" tool set
|
||||
- Enables direct user guidance on implementation decisions
|
||||
- Formats responses with `<answer>` tags to distinguish them from regular conversation
|
||||
- Resets consecutive error counter when used successfully
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Limited to asking one specific question per tool use
|
||||
- Presents suggestions as selectable options in the UI
|
||||
- Cannot force structured responses – users can still respond freely
|
||||
- Excessive use can slow down task completion and create a fragmented experience
|
||||
- Suggested answers must be complete, with no placeholders requiring user edits
|
||||
- No built-in validation for user responses
|
||||
- Contains no mechanism to enforce specific answer formats
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `ask_followup_question` tool is invoked, it follows this process:
|
||||
|
||||
1. **Parameter Validation**: Validates the required `question` parameter and checks for optional suggestions
|
||||
|
||||
- Ensures question text is provided
|
||||
- Parses any suggested answers from the `follow_up` parameter using the `fast-xml-parser` library
|
||||
- Normalizes suggestions into an array format even if there's only one suggestion
|
||||
|
||||
2. **JSON Transformation**: Converts the XML structure into a standardized JSON format for UI display
|
||||
|
||||
```typescript
|
||||
{
|
||||
question: "User's question here",
|
||||
suggest: [
|
||||
{ answer: "Suggestion 1" },
|
||||
{ answer: "Suggestion 2" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
3. **UI Integration**:
|
||||
|
||||
- Passes the JSON structure to the UI layer via the `ask("followup", ...)` method
|
||||
- Displays selectable suggestion buttons to the user in the interface
|
||||
- Creates an interactive experience for selecting or typing a response
|
||||
|
||||
4. **Response Collection and Processing**:
|
||||
|
||||
- Captures user text input and any images included in the response
|
||||
- Wraps user responses in `<answer>` tags when returning to the assistant
|
||||
- Preserves any images included in the user's response
|
||||
- Maintains the conversational context by adding the response to the history
|
||||
- Resets the consecutive error counter when the tool is used successfully
|
||||
|
||||
5. **Error Handling**:
|
||||
- Tracks consecutive mistakes using a counter
|
||||
- Resets the counter when the tool is used successfully
|
||||
- Provides specific error messages:
|
||||
- For missing parameters: "Missing required parameter 'question'"
|
||||
- For XML parsing: "Failed to parse operations: [error message]"
|
||||
- For invalid format: "Invalid operations xml format"
|
||||
- Contains safeguards to prevent tool execution when required parameters are missing
|
||||
- Increments consecutive mistake count when errors occur
|
||||
|
||||
---
|
||||
|
||||
## Workflow Sequence
|
||||
|
||||
The question-answer cycle follows this sequence:
|
||||
|
||||
1. **Information Gap Recognition**: Roo identifies missing information needed to proceed
|
||||
2. **Specific Question Creation**: Roo formulates a clear, targeted question
|
||||
3. **Suggestion Development**: Roo creates relevant suggested answers (optional but recommended)
|
||||
4. **Tool Invocation**: Assistant invokes the tool with question and optional suggestions
|
||||
5. **UI Presentation**: Question and suggestions are displayed to the user as interactive elements
|
||||
6. **User Response**: The user selects a suggestion or provides a custom answer
|
||||
7. **Message Handling**: System handles both partial and complete messages
|
||||
- For streaming responses, processes chunks as they arrive
|
||||
- For complete messages, processes the entire response at once
|
||||
- Maintains state consistency regardless of message chunking
|
||||
8. **Response Processing**: System wraps the response in `<answer>` tags and preserves images
|
||||
9. **Context Integration**: Response is added to the conversation history
|
||||
10. **Task Continuation**: Roo proceeds with the task using the new information
|
||||
|
||||
---
|
||||
|
||||
## Examples When Used
|
||||
|
||||
- When developing a web application, Roo might ask about preferred styling frameworks (Bootstrap, Tailwind, custom CSS)
|
||||
- When creating an API, Roo might ask about authentication methods (JWT, OAuth, API keys)
|
||||
- When refactoring code, Roo might ask about prioritizing performance vs. readability
|
||||
- When setting up a database, Roo might ask about specific schema design preferences
|
||||
- When creating a custom feature, Roo might ask about specific behavior expectations
|
||||
- When troubleshooting errors, Roo might ask about specific environment details
|
||||
|
||||
---
|
||||
|
||||
## Response Format
|
||||
|
||||
When a user responds to a question, the response is formatted with `<answer>` tags for clarity and consistency. This format helps clearly separate the user's answer from other conversation elements.
|
||||
|
||||
Example response format:
|
||||
|
||||
```
|
||||
<answer>
|
||||
Use Tailwind CSS for utility-first styling with maximum flexibility
|
||||
</answer>
|
||||
```
|
||||
|
||||
Key characteristics of the response format:
|
||||
|
||||
- Surrounded by `<answer>` tags
|
||||
- Preserves formatting of the original response
|
||||
- Maintains images if included in the response
|
||||
- Returns the complete text, even for long responses
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Asking about implementation preferences:
|
||||
|
||||
```
|
||||
<ask_followup_question>
|
||||
<question>Which styling approach would you prefer for this web application?</question>
|
||||
<follow_up>
|
||||
<suggest>Use Bootstrap for rapid development with consistent components</suggest>
|
||||
<suggest>Use Tailwind CSS for utility-first styling with maximum flexibility</suggest>
|
||||
<suggest>Use vanilla CSS with custom styling for complete control and minimal dependencies</suggest>
|
||||
</follow_up>
|
||||
</ask_followup_question>
|
||||
```
|
||||
|
||||
Requesting technical clarification:
|
||||
|
||||
```
|
||||
<ask_followup_question>
|
||||
<question>What database should this application use for storing user data?</question>
|
||||
<follow_up>
|
||||
<suggest>MongoDB for flexible schema and document-based storage</suggest>
|
||||
<suggest>PostgreSQL for relational data with strong consistency guarantees</suggest>
|
||||
<suggest>Firebase for real-time updates and simplified backend management</suggest>
|
||||
<suggest>SQLite for lightweight local storage without external dependencies</suggest>
|
||||
</follow_up>
|
||||
</ask_followup_question>
|
||||
```
|
||||
|
||||
Addressing ambiguity in requirements:
|
||||
|
||||
```
|
||||
<ask_followup_question>
|
||||
<question>How should the application handle user authentication?</question>
|
||||
<follow_up>
|
||||
<suggest>Implement email/password authentication with account verification</suggest>
|
||||
<suggest>Use social login providers (Google, GitHub, etc.) for quick signup</suggest>
|
||||
<suggest>Implement both email/password and social login options</suggest>
|
||||
</follow_up>
|
||||
</ask_followup_question>
|
||||
```
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
---
|
||||
description: Learn how the attempt_completion tool signals task completion, presents results, and enables iterative refinement in Roo Code.
|
||||
keywords:
|
||||
- attempt_completion
|
||||
- task completion
|
||||
- result presentation
|
||||
- Roo Code tools
|
||||
- user feedback
|
||||
- task summary
|
||||
---
|
||||
|
||||
# attempt_completion
|
||||
|
||||
The `attempt_completion` tool signals that Roo believes a task is complete and presents results to the user. It provides a summary of what was accomplished, optionally includes a command to demonstrate the result, and supports continued refinement through user feedback.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `result` (required): The final result description summarizing what was accomplished
|
||||
- `command` (optional): A CLI command to execute to demonstrate the result
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool marks the end of a task by presenting a final summary of what was accomplished. It signals to both the user and the system that the current task is complete. When a command is provided, it can also demonstrate the result through a live execution.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When Roo believes the user's task has been completed
|
||||
- When summarizing what changes or improvements were made
|
||||
- When demonstrating the current result with a command execution
|
||||
- When providing a checkpoint for user feedback on the solution
|
||||
- When transitioning from one phase of work to potential refinements
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Provides a clear signal that Roo believes the task is complete
|
||||
- Summarizes accomplishments in a concise message
|
||||
- Optionally demonstrates results through command execution
|
||||
- Enables user feedback for further refinements
|
||||
- Displays results in a special UI format distinct from regular messages
|
||||
- Maintains a structured conversation flow by providing checkpoints
|
||||
- Supports subtask completion within larger workflows
|
||||
- Ensures users receive a clear summary of what was done
|
||||
- Available in all modes as part of the "always available" tool group
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Should not be used until previous tool uses are confirmed successful (guideline, not enforced)
|
||||
- Limited to a single command for result demonstration
|
||||
- Cannot present multiple command options
|
||||
- Commands require user approval before execution
|
||||
- Limited to demonstrating results that can be shown via CLI commands
|
||||
- Cannot be used for partial task completion or progress updates
|
||||
- Result formatting strips XML closing tags through internal processing
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `attempt_completion` tool is invoked, it follows this process:
|
||||
|
||||
1. **Safety Consideration** (guideline, not enforced):
|
||||
|
||||
- The AI is instructed to confirm previous tool uses were successful
|
||||
- This is a best practice rather than a programmatically enforced mechanism
|
||||
|
||||
2. **Result Presentation**:
|
||||
|
||||
- Displays the completion message to the user in a special "completion_result" UI format
|
||||
- Removes XML closing tags from the result text using the `removeClosingTag` function
|
||||
- Presents the result differently than regular messages for visual distinction
|
||||
|
||||
3. **Command Execution** (if provided):
|
||||
|
||||
- Requests user approval before executing the command
|
||||
- Only executes if the user approves
|
||||
- Executes the command using the system's command execution functionality
|
||||
- Shows the result of the command to the user
|
||||
|
||||
4. **Feedback Collection**:
|
||||
|
||||
- Waits for user feedback on the completion result
|
||||
- Processes this feedback and returns it to the AI
|
||||
- Enables continued refinement based on user input
|
||||
|
||||
5. **Task Completion and Continuation**:
|
||||
|
||||
- Signals the task as completed in the system
|
||||
- Captures telemetry data for the completed task
|
||||
- For subtasks, offers to finish the subtask and resume the parent task
|
||||
- Supports continued conversation through the feedback mechanism
|
||||
|
||||
6. **Implementation Integration**:
|
||||
- Tool results are parsed through the system's parsing mechanism in `parse-assistant-message.ts`
|
||||
- The tool is part of the "ALWAYS_AVAILABLE_TOOLS" constant, making it available in all modes
|
||||
|
||||
---
|
||||
|
||||
## Result Formatting Guidelines
|
||||
|
||||
The result message should follow these guidelines:
|
||||
|
||||
- Clearly communicate what was accomplished
|
||||
- Be concise but complete
|
||||
- Focus on the value delivered to the user
|
||||
- Avoid unnecessary pleasantries or filler text
|
||||
- Maintain a professional, straightforward tone
|
||||
- Present information in a way that's easy to scan and understand
|
||||
- Acknowledge that the user may provide feedback for further refinements
|
||||
|
||||
Note: The system automatically strips XML closing tags from the result text through the `removeClosingTag` function.
|
||||
|
||||
---
|
||||
|
||||
## Command Selection Guidelines
|
||||
|
||||
When including a command, follow these guidelines:
|
||||
|
||||
- Choose commands that visually demonstrate the result
|
||||
- Prefer commands that show the user what was created or modified
|
||||
- Examples include:
|
||||
- `open index.html` to display a created website
|
||||
- `npm start` to launch a development server
|
||||
- `python app.py` to run a created application
|
||||
- Avoid commands that merely print text (like `echo` or `cat`)
|
||||
- Remember that commands require user approval before execution
|
||||
- Ensure the command is valid for the user's operating system
|
||||
|
||||
---
|
||||
|
||||
## Feedback and UI Representation
|
||||
|
||||
The `attempt_completion` tool has a unique feedback mechanism:
|
||||
|
||||
- Results appear in a special "completion_result" UI format distinct from regular messages
|
||||
- The system waits for user feedback after presenting the result
|
||||
- Feedback is processed and returned to Roo for further refinements
|
||||
- This creates an iterative improvement cycle rather than ending the conversation
|
||||
- The UI includes special elements for providing feedback
|
||||
- Results serve as clear checkpoints in the conversation flow
|
||||
|
||||
When used within subtasks:
|
||||
|
||||
- The system offers to finish the subtask and resume the parent task
|
||||
- If approved, the subtask is completed with a summary
|
||||
- The parent task resumes with context from the completed subtask
|
||||
- This enables complex, nested workflows while maintaining context
|
||||
|
||||
---
|
||||
|
||||
## Examples When Used
|
||||
|
||||
- When creating a website, Roo presents the solution and uses `open index.html` to show the result. The user provides feedback, and Roo refines the design.
|
||||
- When implementing a feature, Roo summarizes the changes and their impact. The user requests additional test coverage, and Roo continues the task.
|
||||
- When fixing bugs, Roo explains what was fixed and how the solution addresses the root cause. The user confirms the fix works.
|
||||
- When setting up a development environment, Roo confirms all components are installed. The user requests additional configuration, and Roo continues.
|
||||
- When creating a data processing script, Roo summarizes the functionality. The user suggests performance improvements, and Roo optimizes the code.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Completing a task with just a result summary:
|
||||
|
||||
```
|
||||
<attempt_completion>
|
||||
<result>
|
||||
I've updated the CSS for the navigation bar to match the design mockup. The changes include:
|
||||
1. Updated color scheme to use the new brand colors
|
||||
2. Added responsive behavior for mobile devices
|
||||
3. Fixed the alignment issues in the dropdown menus
|
||||
</result>
|
||||
</attempt_completion>
|
||||
```
|
||||
|
||||
Completing a task with a result and demonstration command:
|
||||
|
||||
```
|
||||
<attempt_completion>
|
||||
<result>
|
||||
I've created a simple React application with the following features:
|
||||
1. A responsive landing page with the requested hero section
|
||||
2. A working contact form with validation
|
||||
3. Navigation between all three requested pages
|
||||
4. Styling that matches the provided design mockup
|
||||
</result>
|
||||
<command>npm start</command>
|
||||
</attempt_completion>
|
||||
```
|
||||
|
||||
Completing a Python script task with execution:
|
||||
|
||||
```
|
||||
<attempt_completion>
|
||||
<result>
|
||||
I've created a data processing script that:
|
||||
1. Reads the CSV files from the data directory
|
||||
2. Cleans and normalizes the data as requested
|
||||
3. Generates the summary statistics in JSON format
|
||||
4. Outputs the processed data to the output directory
|
||||
</result>
|
||||
<command>python process_data.py</command>
|
||||
</attempt_completion>
|
||||
```
|
||||
275
apps/docs/docs/advanced-usage/available-tools/codebase-search.md
Normal file
275
apps/docs/docs/advanced-usage/available-tools/codebase-search.md
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
---
|
||||
description: Perform intelligent semantic searches across your codebase using AI embeddings to find relevant code by meaning, not just keywords.
|
||||
keywords:
|
||||
- codebase_search
|
||||
- semantic search
|
||||
- AI embeddings
|
||||
- code search
|
||||
- Roo Code tools
|
||||
- vector search
|
||||
- Qdrant
|
||||
---
|
||||
|
||||
# codebase_search
|
||||
|
||||
:::info Setup Required
|
||||
The `codebase_search` tool is part of the [Codebase Indexing](/features/codebase-indexing) feature. It requires additional setup including an embedding provider and vector database.
|
||||
:::
|
||||
|
||||
The `codebase_search` tool performs semantic searches across your entire codebase using AI embeddings. Unlike traditional text-based search, it understands the meaning of your queries and finds relevant code even when exact keywords don't match.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `query` (required): Natural language search query describing what you're looking for
|
||||
- `path` (optional): Directory path to limit search scope to a specific part of your codebase
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool searches through your indexed codebase using semantic similarity rather than exact text matching. It finds code blocks that are conceptually related to your query, even if they don't contain the exact words you searched for. Results include relevant code snippets with file paths, line numbers, and similarity scores.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When Roo needs to find code related to specific functionality across your project
|
||||
- When looking for implementation patterns or similar code structures
|
||||
- When searching for error handling, authentication, or other conceptual code patterns
|
||||
- When exploring unfamiliar codebases to understand how features are implemented
|
||||
- When finding related code that might be affected by changes or refactoring
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Semantic Understanding**: Finds code by meaning rather than exact keyword matches
|
||||
- **Cross-Project Search**: Searches across your entire indexed codebase, not just open files
|
||||
- **Contextual Results**: Returns code snippets with file paths and line numbers for easy navigation
|
||||
- **Similarity Scoring**: Results ranked by relevance with similarity scores (0-1 scale)
|
||||
- **Scope Filtering**: Optional path parameter to limit searches to specific directories
|
||||
- **Intelligent Ranking**: Results sorted by semantic relevance to your query
|
||||
- **UI Integration**: Results displayed with syntax highlighting and navigation links
|
||||
- **Performance Optimized**: Fast vector-based search with configurable result limits
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
This tool is only available when the Codebase Indexing feature is properly configured:
|
||||
|
||||
- **Feature Configured**: Codebase Indexing must be configured in settings
|
||||
- **Embedding Provider**: OpenAI API key or Ollama configuration required
|
||||
- **Vector Database**: Qdrant instance running and accessible
|
||||
- **Index Status**: Codebase must be indexed (status: "Indexed" or "Indexing")
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Requires Configuration**: Depends on external services (embedding provider + Qdrant)
|
||||
- **Index Dependency**: Only searches through indexed code blocks
|
||||
- **Result Limits**: Maximum of 50 results per search to maintain performance
|
||||
- **Similarity Threshold**: Only returns results above similarity threshold (default: 0.4, configurable)
|
||||
- **File Size Limits**: Limited to files under 1MB that were successfully indexed
|
||||
- **Language Support**: Effectiveness depends on Tree-sitter language support
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `codebase_search` tool is invoked, it follows this process:
|
||||
|
||||
1. **Availability Validation**:
|
||||
|
||||
- Verifies that the CodeIndexManager is available and initialized
|
||||
- Confirms codebase indexing is enabled in settings
|
||||
- Checks that indexing is properly configured (API keys, Qdrant URL)
|
||||
- Validates the current index state allows searching
|
||||
|
||||
2. **Query Processing**:
|
||||
|
||||
- Takes your natural language query and generates an embedding vector
|
||||
- Uses the same embedding provider configured for indexing (OpenAI or Ollama)
|
||||
- Converts the semantic meaning of your query into a mathematical representation
|
||||
|
||||
3. **Vector Search Execution**:
|
||||
|
||||
- Searches the Qdrant vector database for similar code embeddings
|
||||
- Uses cosine similarity to find the most relevant code blocks
|
||||
- Applies the minimum similarity threshold (default: 0.4, configurable) to filter results
|
||||
- Limits results to 50 matches for optimal performance
|
||||
|
||||
4. **Path Filtering** (if specified):
|
||||
|
||||
- Filters results to only include files within the specified directory path
|
||||
- Uses normalized path comparison for accurate filtering
|
||||
- Maintains relevance ranking within the filtered scope
|
||||
|
||||
5. **Result Processing and Formatting**:
|
||||
|
||||
- Converts absolute file paths to workspace-relative paths
|
||||
- Structures results with file paths, line ranges, similarity scores, and code content
|
||||
- Formats for both AI consumption and UI display with syntax highlighting
|
||||
|
||||
6. **Dual Output Format**:
|
||||
- **AI Output**: Structured text format with query, file paths, scores, and code chunks
|
||||
- **UI Output**: JSON format with syntax highlighting and navigation capabilities
|
||||
|
||||
---
|
||||
|
||||
## Search Query Best Practices
|
||||
|
||||
### Effective Query Patterns
|
||||
|
||||
**Good: Conceptual and specific**
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>user authentication and password validation</query>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
**Good: Feature-focused**
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>database connection pool setup</query>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
**Good: Problem-oriented**
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>error handling for API requests</query>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
**Less effective: Too generic**
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>function</query>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
### Query Types That Work Well
|
||||
|
||||
- **Functional Descriptions**: "file upload processing", "email validation logic"
|
||||
- **Technical Patterns**: "singleton pattern implementation", "factory method usage"
|
||||
- **Domain Concepts**: "user profile management", "payment processing workflow"
|
||||
- **Architecture Components**: "middleware configuration", "database migration scripts"
|
||||
|
||||
---
|
||||
|
||||
## Directory Scoping
|
||||
|
||||
Use the optional `path` parameter to focus searches on specific parts of your codebase:
|
||||
|
||||
**Search within API modules:**
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>endpoint validation middleware</query>
|
||||
<path>src/api</path>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
**Search in test files:**
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>mock data setup patterns</query>
|
||||
<path>tests</path>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
**Search specific feature directories:**
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>component state management</query>
|
||||
<path>src/components/auth</path>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Result Interpretation
|
||||
|
||||
### Similarity Scores
|
||||
|
||||
- **0.8-1.0**: Highly relevant matches, likely exactly what you're looking for
|
||||
- **0.6-0.8**: Good matches with strong conceptual similarity
|
||||
- **0.4-0.6**: Potentially relevant but may require review
|
||||
- **Below 0.4**: Filtered out as too dissimilar
|
||||
|
||||
### Result Structure
|
||||
|
||||
Each search result includes:
|
||||
|
||||
- **File Path**: Workspace-relative path to the file containing the match
|
||||
- **Score**: Similarity score indicating relevance (0.4-1.0)
|
||||
- **Line Range**: Start and end line numbers for the code block
|
||||
- **Code Chunk**: The actual code content that matched your query
|
||||
|
||||
---
|
||||
|
||||
## Examples When Used
|
||||
|
||||
- When implementing a new feature, Roo searches for "authentication middleware" to understand existing patterns before writing new code.
|
||||
- When debugging an issue, Roo searches for "error handling in API calls" to find related error patterns across the codebase.
|
||||
- When refactoring code, Roo searches for "database transaction patterns" to ensure consistency across all database operations.
|
||||
- When onboarding to a new codebase, Roo searches for "configuration loading" to understand how the application bootstraps.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Searching for authentication-related code across the entire project:
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>user login and authentication logic</query>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
Finding database-related code in a specific directory:
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>database connection and query execution</query>
|
||||
<path>src/data</path>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
Looking for error handling patterns in API code:
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>HTTP error responses and exception handling</query>
|
||||
<path>src/api</path>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
Searching for testing utilities and mock setups:
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>test setup and mock data creation</query>
|
||||
<path>tests</path>
|
||||
</codebase_search>
|
||||
```
|
||||
|
||||
Finding configuration and environment setup code:
|
||||
|
||||
```xml
|
||||
<codebase_search>
|
||||
<query>environment variables and application configuration</query>
|
||||
</codebase_search>
|
||||
```
|
||||
91
apps/docs/docs/advanced-usage/available-tools/edit-file.md
Normal file
91
apps/docs/docs/advanced-usage/available-tools/edit-file.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
description: Replace a uniquely-identified occurrence of text in files using the edit_file search-and-replace tool in Roo Code.
|
||||
keywords:
|
||||
- edit_file
|
||||
- search and replace
|
||||
- file editing
|
||||
- text replacement
|
||||
- Roo Code tools
|
||||
- code modifications
|
||||
---
|
||||
|
||||
# edit_file
|
||||
|
||||
The `edit_file` tool performs targeted search-and-replace operations on files. By default it replaces **exactly one** uniquely-identified occurrence and errors if multiple matches are found. It also supports a special file-creation mode when `old_string` is empty.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `file_path` (required): The path of the file to modify relative to the current working directory.
|
||||
- `old_string` (required): The exact text to search for and replace. Pass an empty string (`""`) to create a new file or append to an existing file.
|
||||
- `new_string` (required): The replacement text.
|
||||
- `expected_replacements` (optional): Expected number of replacements (defaults to 1). The operation fails if the actual count doesn't match. Use this only when intentionally replacing more than one occurrence.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool searches for an exact string in a file and replaces **exactly one** occurrence with new text. The search string must uniquely identify the target location. If multiple matches are found, the tool returns an error unless `expected_replacements` is explicitly set to match. When `old_string` is empty, the tool creates a new file or appends `new_string` to an existing file.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When making a targeted change to a specific, uniquely identifiable location in a file
|
||||
- When updating a specific string literal or configuration value at a known location
|
||||
- When fixing a specific instance of a typo or outdated terminology
|
||||
- When replacing a uniquely-identified occurrence of a deprecated API or import path
|
||||
- When creating a new file or appending content to an existing file (`old_string=""`)
|
||||
- When you need to ensure exact match replacement without fuzzy logic
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Replaces **exactly one** uniquely-identified occurrence by default
|
||||
- Errors if multiple matches are found (unless `expected_replacements` is explicitly set)
|
||||
- `old_string=""` mode: creates a new file or appends content to an existing file
|
||||
- Exact string matching (no regex or fuzzy matching)
|
||||
- Optional `expected_replacements` for intentional multi-occurrence replacements
|
||||
- Shows preview of changes before applying
|
||||
- Fails safely if actual replacement count doesn't match `expected_replacements`
|
||||
- Preserves file formatting and structure
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires exact string matches (case-sensitive, whitespace-sensitive)
|
||||
- Errors if the search string matches more than one location (unless `expected_replacements` is set)
|
||||
- Cannot use regular expressions or patterns
|
||||
- Not suitable for context-dependent replacements
|
||||
- Less precise than [`apply_diff`](/advanced-usage/available-tools/apply-diff) for complex edits
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `edit_file` tool is invoked, it follows this process:
|
||||
|
||||
1. **Parameter Validation**: Validates required `file_path`, `old_string`, and `new_string` parameters.
|
||||
2. **File Creation Mode**: If `old_string` is empty (`""`), creates the file with `new_string` as content (or appends if the file already exists), then stops.
|
||||
3. **File Loading**: Reads the target file content.
|
||||
4. **Uniqueness Check**: Counts occurrences of `old_string`. If the count doesn't match `expected_replacements` (default: 1), returns an error.
|
||||
5. **Replacement**: Replaces the matched occurrence(s) with `new_string`.
|
||||
6. **User Review**: Shows a preview of changes for user approval.
|
||||
7. **Application**: Applies changes to the file if approved.
|
||||
8. **Feedback**: Reports the number of replacements made.
|
||||
|
||||
---
|
||||
|
||||
## Relation to Other Tools
|
||||
|
||||
- `edit_file`: Replaces **exactly one** uniquely-identified occurrence by default; supports `old_string=""` file creation (this tool)
|
||||
- [`edit`](/advanced-usage/available-tools/edit): Replaces **first occurrence** only (unless `replace_all: true`)
|
||||
- [`search_replace`](/advanced-usage/available-tools/search-replace): Also replaces **exactly one** uniquely-identified occurrence
|
||||
- [`apply_diff`](/advanced-usage/available-tools/apply-diff): Use for precise, context-aware edits with fuzzy matching
|
||||
|
||||
These are different implementations of search-and-replace with varying capabilities.
|
||||
91
apps/docs/docs/advanced-usage/available-tools/edit.md
Normal file
91
apps/docs/docs/advanced-usage/available-tools/edit.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
description: Replace the first or all occurrences of text using the edit search-and-replace tool in Roo Code.
|
||||
keywords:
|
||||
- edit
|
||||
- search and replace
|
||||
- file editing
|
||||
- text replacement
|
||||
- Roo Code tools
|
||||
- code modifications
|
||||
---
|
||||
|
||||
# edit
|
||||
|
||||
The `edit` tool performs search-and-replace operations on files, replacing either the **first occurrence** (default) or **all occurrences** when explicitly specified. It provides flexible control over replacement scope.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `file_path` (required): The path of the file to modify relative to the current working directory.
|
||||
- `old_string` (required): The exact text to search for and replace.
|
||||
- `new_string` (required): The text to replace occurrences with.
|
||||
- `replace_all` (optional): Boolean flag. When `true`, replaces all occurrences. When `false` or omitted, replaces only the first occurrence.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool searches for an exact string in a file and replaces either the first occurrence or all occurrences based on the `replace_all` parameter. By default, it replaces only the **first match**, making it suitable for targeted single-instance changes.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When updating a single specific occurrence of text (default behavior)
|
||||
- When the first instance requires different handling than subsequent ones
|
||||
- When you need explicit control over whether to replace once or globally
|
||||
- When making targeted changes to specific instances without affecting others
|
||||
- When replacing all instances by setting `replace_all: true`
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Replaces **first occurrence only** by default (conservative behavior)
|
||||
- Optional `replace_all` parameter for global replacement
|
||||
- Exact string matching (no regex or fuzzy matching)
|
||||
- Shows preview of changes before applying
|
||||
- Preserves file formatting and structure
|
||||
- User approval required before applying changes
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires exact string matches (case-sensitive, whitespace-sensitive)
|
||||
- Cannot use regular expressions or patterns
|
||||
- Not suitable for context-dependent replacements requiring code analysis
|
||||
- Less precise than [`apply_diff`](/advanced-usage/available-tools/apply-diff) for complex edits
|
||||
- Cannot specify which specific occurrence to replace (first vs. second vs. third)
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `edit` tool is invoked, it follows this process:
|
||||
|
||||
1. **Parameter Validation**: Validates required `file_path`, `old_string`, and `new_string` parameters.
|
||||
2. **File Loading**: Reads the target file content.
|
||||
3. **Search Operation**: Searches for occurrences of `old_string` in the file.
|
||||
4. **Replacement Logic**:
|
||||
- If `replace_all` is `false` or omitted: replaces only the first occurrence
|
||||
- If `replace_all` is `true`: replaces all occurrences
|
||||
5. **User Review**: Shows a preview of changes for user approval.
|
||||
6. **Application**: Applies changes to the file if approved.
|
||||
7. **Feedback**: Reports the result of the operation.
|
||||
|
||||
---
|
||||
|
||||
## Relation to Other Tools
|
||||
|
||||
- `edit`: Replaces **first occurrence** by default (this tool)
|
||||
- [`edit_file`](/advanced-usage/available-tools/edit-file): Always replaces **all occurrences**
|
||||
- [`search_replace`](/advanced-usage/available-tools/search-replace): Always replaces **all occurrences**
|
||||
- [`apply_diff`](/advanced-usage/available-tools/apply-diff): Use for precise, context-aware edits with fuzzy matching
|
||||
|
||||
:::info Deprecated Alias
|
||||
`SearchAndReplaceTool` is a deprecated internal alias for `EditTool`. They are the same tool.
|
||||
:::
|
||||
194
apps/docs/docs/advanced-usage/available-tools/execute-command.md
Normal file
194
apps/docs/docs/advanced-usage/available-tools/execute-command.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
---
|
||||
description: Execute terminal commands in Roo Code for system operations, dependency installation, builds, and development workflows.
|
||||
keywords:
|
||||
- execute_command
|
||||
- CLI commands
|
||||
- terminal
|
||||
- system operations
|
||||
- Roo Code tools
|
||||
- command execution
|
||||
- shell integration
|
||||
---
|
||||
|
||||
# execute_command
|
||||
|
||||
The `execute_command` tool runs CLI commands on the user's system. It allows Roo to perform system operations, install dependencies, build projects, start servers, and execute other terminal-based tasks needed to accomplish user objectives.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `command` (required): The CLI command to execute. Must be valid for the user's operating system.
|
||||
- `cwd` (optional): The working directory to execute the command in. If not provided, the current working directory is used.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool executes terminal commands directly on the user's system, enabling a wide range of operations from file manipulations to running development servers. Commands run in managed terminal instances with real-time output capture, integrated with VS Code's terminal system for optimal performance and security.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When installing project dependencies (npm install, pip install, etc.)
|
||||
- When building or compiling code (make, npm run build, etc.)
|
||||
- When starting development servers or running applications
|
||||
- When initializing new projects (git init, npm init, etc.)
|
||||
- When performing file operations beyond what other tools provide
|
||||
- When running tests or linting operations
|
||||
- When needing to execute specialized commands for specific technologies
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Integrates with VS Code shell API for reliable terminal execution
|
||||
- Reuses terminal instances when possible through a registry system
|
||||
- Captures command output line by line with real-time feedback
|
||||
- Supports long-running commands that continue in the background
|
||||
- Allows specification of custom working directories
|
||||
- Maintains terminal history and state across command executions
|
||||
- Handles complex command chains appropriate for the user's shell
|
||||
- Provides detailed command completion status and exit code interpretation
|
||||
- Supports interactive terminal applications with user feedback loop
|
||||
- Shows terminals during execution for transparency
|
||||
- Validates commands for security using shell-quote parsing
|
||||
- Blocks potentially dangerous subshell execution patterns
|
||||
- Integrates with RooIgnore system for file access control
|
||||
- Handles terminal escape sequences for clean output
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Command access may be restricted by RooIgnore rules and security validations
|
||||
- Commands with elevated permission requirements may need user configuration
|
||||
- Behavior may vary across operating systems for certain commands
|
||||
- Very long-running commands may require specific handling
|
||||
- File paths should be properly escaped according to the OS shell rules
|
||||
- Not all terminal features may work with remote development scenarios
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `execute_command` tool is invoked, it follows this process:
|
||||
|
||||
1. **Command Validation and Security Checks**:
|
||||
|
||||
- Parses the command using shell-quote to identify components
|
||||
- Validates against security restrictions (subshell usage, restricted files)
|
||||
- Checks against RooIgnore rules for file access permissions
|
||||
- Ensures the command meets system security requirements
|
||||
|
||||
2. **Terminal Management**:
|
||||
|
||||
- Gets or creates a terminal through TerminalRegistry
|
||||
- Sets up the working directory context
|
||||
- Prepares event listeners for output capture
|
||||
- Shows the terminal for user visibility
|
||||
|
||||
3. **Command Execution and Monitoring**:
|
||||
|
||||
- Executes via VS Code's shellIntegration API
|
||||
- Captures output with escape sequence processing
|
||||
- Throttles output handling (100ms intervals)
|
||||
- Monitors for command completion or errors
|
||||
- Detects "hot" processes like compilers for special handling
|
||||
|
||||
4. **Result Processing**:
|
||||
- Strips ANSI/VS Code escape sequences for clean output
|
||||
- Interprets exit codes with detailed signal information
|
||||
- Updates working directory tracking if changed by command
|
||||
- Provides command status with appropriate context
|
||||
|
||||
---
|
||||
|
||||
## Terminal Implementation Details
|
||||
|
||||
The tool uses a sophisticated terminal management system:
|
||||
|
||||
1. **First Priority: Terminal Reuse**
|
||||
|
||||
- The TerminalRegistry tries to reuse existing terminals when possible
|
||||
- This reduces proliferation of terminal instances and improves performance
|
||||
- Terminal state (working directory, history) is preserved across commands
|
||||
|
||||
2. **Second Priority: Security Validation**
|
||||
|
||||
- Commands are parsed using shell-quote for component analysis
|
||||
- Dangerous patterns like `$(...)` and backticks are blocked
|
||||
- Commands are checked against RooIgnore rules for file access control
|
||||
- A prefix-based allowlist system validates command patterns
|
||||
|
||||
3. **Performance Optimizations**
|
||||
|
||||
- Output is processed in 100ms throttled intervals to prevent UI overload
|
||||
- Zero-copy buffer management uses index-based tracking for efficiency
|
||||
- Special handling for compilation and "hot" processes
|
||||
- Platform-specific optimizations for Windows PowerShell
|
||||
|
||||
4. **Error and Signal Handling**
|
||||
- Exit codes are mapped to detailed signal information (SIGTERM, SIGKILL, etc.)
|
||||
- Core dump detection for critical failures
|
||||
- Working directory changes are tracked and handled automatically
|
||||
- Clean recovery from terminal disconnection scenarios
|
||||
|
||||
---
|
||||
|
||||
## Examples When Used
|
||||
|
||||
- When setting up a new project, Roo runs initialization commands like `npm init -y` followed by installing dependencies.
|
||||
- When building a web application, Roo executes build commands like `npm run build` to compile assets.
|
||||
- When deploying code, Roo runs git commands to commit and push changes to a repository.
|
||||
- When troubleshooting, Roo executes diagnostic commands to gather system information.
|
||||
- When starting a development server, Roo launches the appropriate server command (e.g., `npm start`).
|
||||
- When running tests, Roo executes the test runner command for the project's testing framework.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Running a simple command in the current directory:
|
||||
|
||||
```
|
||||
<execute_command>
|
||||
<command>npm run dev</command>
|
||||
</execute_command>
|
||||
```
|
||||
|
||||
Installing dependencies for a project:
|
||||
|
||||
```
|
||||
<execute_command>
|
||||
<command>npm install express mongodb mongoose dotenv</command>
|
||||
</execute_command>
|
||||
```
|
||||
|
||||
Running multiple commands in sequence:
|
||||
|
||||
```
|
||||
<execute_command>
|
||||
<command>mkdir -p src/components && touch src/components/App.js</command>
|
||||
</execute_command>
|
||||
```
|
||||
|
||||
Executing a command in a specific directory:
|
||||
|
||||
```
|
||||
<execute_command>
|
||||
<command>git status</command>
|
||||
<cwd>./my-project</cwd>
|
||||
</execute_command>
|
||||
```
|
||||
|
||||
Building and then starting a project:
|
||||
|
||||
```
|
||||
<execute_command>
|
||||
<command>npm run build && npm start</command>
|
||||
</execute_command>
|
||||
```
|
||||
128
apps/docs/docs/advanced-usage/available-tools/generate-image.md
Normal file
128
apps/docs/docs/advanced-usage/available-tools/generate-image.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
description: Generate or edit images using AI models through the generate_image tool in Roo Code.
|
||||
keywords:
|
||||
- generate_image
|
||||
- AI images
|
||||
- image generation
|
||||
- image editing
|
||||
- OpenRouter
|
||||
- Roo Code tools
|
||||
- experimental
|
||||
---
|
||||
|
||||
# generate_image
|
||||
|
||||
The `generate_image` tool creates new images from text prompts or modifies existing images using AI models. It supports two providers: **OpenRouter** and the **Roo provider**. This experimental feature enables visual content generation and transformation within your development workflow.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `prompt` (required): The text description of what to generate or how to edit the image.
|
||||
- `path` (required): The file path where the generated/edited image should be saved (relative to the workspace). The tool automatically adds the appropriate extension if not provided.
|
||||
- `image` (optional): The file path to an input image to edit or transform (relative to the workspace). Supported formats: PNG, JPG, JPEG, GIF, WEBP.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool generates images from text descriptions or applies transformations to existing images using AI models. When no input image is provided, it creates new images from scratch. When an input image is provided, it applies the prompt as editing instructions to transform the image.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When creating visual assets for documentation, mockups, or prototypes
|
||||
- When generating placeholder images or illustrations
|
||||
- When transforming existing images (style transfer, enhancement, modifications)
|
||||
- When creating diagrams or visual explanations from descriptions
|
||||
- When prototyping UI elements visually
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Text-to-image generation**: Create images from descriptive prompts
|
||||
- **Image-to-image transformation**: Edit or transform existing images
|
||||
- Supports multiple input formats (PNG, JPG, JPEG, GIF, WEBP)
|
||||
- Automatic file extension handling
|
||||
- Powered by **OpenRouter** or the **Roo provider** for access to various AI models
|
||||
- Experimental feature with ongoing improvements
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires OpenRouter or Roo provider API configuration
|
||||
- Image quality depends on the AI model and prompt quality
|
||||
- Generation time varies based on complexity and model
|
||||
- Experimental feature: behavior may change in future releases
|
||||
- API usage may incur costs based on OpenRouter pricing
|
||||
- Some image transformations may not produce expected results
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `generate_image` tool is invoked, it follows this process:
|
||||
|
||||
1. **Parameter Validation**: Validates required `prompt` and `path` parameters.
|
||||
2. **Mode Selection**:
|
||||
- If `image` parameter is provided: operates in **edit mode** (transform existing image)
|
||||
- Otherwise: operates in **generation mode** (create new image from prompt)
|
||||
3. **API Request**: Sends request to the configured provider (OpenRouter or Roo) with prompt and optional input image.
|
||||
4. **Image Processing**: Receives generated/edited image from the API.
|
||||
5. **File Saving**: Saves the image to the specified `path` with appropriate extension.
|
||||
6. **Feedback**: Reports success and the location of the generated image.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Generating a new image:
|
||||
|
||||
```
|
||||
<generate_image>
|
||||
<prompt>A beautiful sunset over mountains with vibrant orange and purple colors</prompt>
|
||||
<path>images/sunset.png</path>
|
||||
</generate_image>
|
||||
```
|
||||
|
||||
Editing an existing image:
|
||||
|
||||
```
|
||||
<generate_image>
|
||||
<prompt>Transform this image into a watercolor painting style</prompt>
|
||||
<path>images/watercolor-output.png</path>
|
||||
<image>images/original-photo.jpg</image>
|
||||
</generate_image>
|
||||
```
|
||||
|
||||
Upscaling and enhancing:
|
||||
|
||||
```
|
||||
<generate_image>
|
||||
<prompt>Upscale this image to higher resolution, enhance details, improve clarity and sharpness while maintaining the original content and composition</prompt>
|
||||
<path>images/enhanced-photo.png</path>
|
||||
<image>images/low-res-photo.jpg</image>
|
||||
</generate_image>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relation to Features
|
||||
|
||||
The `generate_image` tool is the programmatic interface to the [Image Generation](/features/image-generation) feature. For comprehensive documentation on configuration, model selection, API setup, and advanced usage, see the [Image Generation feature documentation](/features/image-generation).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Image generation requires OpenRouter API configuration. See the [Image Generation](/features/image-generation) feature page for detailed setup instructions including:
|
||||
|
||||
- OpenRouter API key configuration
|
||||
- Model selection and capabilities
|
||||
- Best practices for prompts
|
||||
- Troubleshooting and limitations
|
||||
168
apps/docs/docs/advanced-usage/available-tools/list-files.md
Normal file
168
apps/docs/docs/advanced-usage/available-tools/list-files.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
---
|
||||
description: Learn how the list_files tool helps Roo Code explore project structures, list directories, and navigate codebases with recursive and filtered listing capabilities.
|
||||
keywords:
|
||||
- list_files
|
||||
- Roo Code tools
|
||||
- directory listing
|
||||
- file exploration
|
||||
- project structure
|
||||
- recursive listing
|
||||
- codebase navigation
|
||||
- VS Code AI
|
||||
---
|
||||
|
||||
# list_files
|
||||
|
||||
The `list_files` tool displays the files and directories within a specified location. It helps Roo understand your project structure and navigate your codebase effectively.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `path` (required): The path of the directory to list contents for, relative to the current working directory
|
||||
- `recursive` (optional): Whether to list files recursively. Use `true` for recursive listing, `false` or omit for top-level only.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool lists all files and directories in a specified location, providing a clear overview of your project structure. It can either show just the top-level contents or recursively explore subdirectories.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When Roo needs to understand your project structure
|
||||
- When Roo explores what files are available before reading specific ones
|
||||
- When Roo maps a codebase to better understand its organization
|
||||
- Before using more targeted tools like `read_file` or `search_files`
|
||||
- When Roo needs to check for specific file types (like configuration files) across a project
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Lists both files and directories with directories clearly marked
|
||||
- Offers both recursive and non-recursive listing modes
|
||||
- Intelligently ignores common large directories like `node_modules` and `.git` in recursive mode
|
||||
- Respects `.gitignore` rules when in recursive mode
|
||||
- Marks files ignored by `.rooignore` with a lock symbol (🔒) when `showRooIgnoredFiles` is enabled
|
||||
- Optimizes file listing performance by leveraging the `ripgrep` tool.
|
||||
- Sorts results to show directories before their contents, maintaining a logical hierarchy
|
||||
- Presents results in a clean, organized format
|
||||
- Automatically creates a mental map of your project structure
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- File listing is capped at about 200 files by default to prevent performance issues
|
||||
- The underlying `ripgrep` file listing process has a 10-second timeout; if exceeded, partial results may be returned.
|
||||
- When the file limit is hit, it adds a note suggesting to use `list_files` on specific subdirectories
|
||||
- Not designed for confirming the existence of files you've just created
|
||||
- May have reduced performance in very large directory structures
|
||||
- Cannot list files in root or home directories for security reasons
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `list_files` tool is invoked, it follows this process:
|
||||
|
||||
1. **Parameter Validation**: Validates the required `path` parameter and optional `recursive` parameter
|
||||
2. **Path Resolution**: Resolves the relative path to an absolute path
|
||||
3. **Security Checks**: Prevents listing files in sensitive locations like root or home directories
|
||||
4. **Directory/File Scanning**:
|
||||
- Uses the `ripgrep` tool to efficiently list files, applying a 10-second timeout.
|
||||
- Uses Node.js `fs` module to list directories.
|
||||
- Applies different filtering logic for recursive vs. non-recursive modes.
|
||||
5. **Result Filtering**:
|
||||
- In recursive mode, skips common large directories like `node_modules`, `.git`, etc.
|
||||
- Respects `.gitignore` rules when in recursive mode
|
||||
- Handles `.rooignore` patterns, either hiding files or marking them with a lock symbol
|
||||
6. **Formatting**:
|
||||
- Marks directories with a trailing slash (`/`)
|
||||
- Sorts results to show directories before their contents for logical hierarchy
|
||||
- Marks ignored files with a lock symbol (🔒) when `showRooIgnoredFiles` is enabled
|
||||
- Caps results at 200 files by default with a note about using subdirectories
|
||||
- Organizes results for readability
|
||||
|
||||
---
|
||||
|
||||
## File Listing Format
|
||||
|
||||
The file listing results include:
|
||||
|
||||
- Each file path is displayed on its own line
|
||||
- Directories are marked with a trailing slash (`/`)
|
||||
- Files ignored by `.rooignore` are marked with a lock symbol (🔒) when `showRooIgnoredFiles` is enabled
|
||||
- Results are sorted logically with directories appearing before their contents
|
||||
- When the file limit is reached, a message appears suggesting to use `list_files` on specific subdirectories
|
||||
|
||||
Example output format:
|
||||
|
||||
```
|
||||
src/
|
||||
src/components/
|
||||
src/components/Button.tsx
|
||||
src/components/Header.tsx
|
||||
src/utils/
|
||||
src/utils/helpers.ts
|
||||
src/index.ts
|
||||
...
|
||||
File listing truncated (showing 200 of 543 files). Use list_files on specific subdirectories for more details.
|
||||
```
|
||||
|
||||
When `.rooignore` files are used and `showRooIgnoredFiles` is enabled:
|
||||
|
||||
```
|
||||
src/
|
||||
src/components/
|
||||
src/components/Button.tsx
|
||||
src/components/Header.tsx
|
||||
🔒 src/secrets.json
|
||||
src/utils/
|
||||
src/utils/helpers.ts
|
||||
src/index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples When Used
|
||||
|
||||
- When starting a new task, Roo may list the project files to understand its structure before diving into specific code.
|
||||
- When asked to find specific types of files (like all JavaScript files), Roo first lists directories to know where to look.
|
||||
- When providing recommendations for code organization, Roo examines the current project structure first.
|
||||
- When setting up a new feature, Roo lists related directories to understand the project conventions.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Listing top-level files in the current directory:
|
||||
|
||||
```
|
||||
<list_files>
|
||||
<path>.</path>
|
||||
</list_files>
|
||||
```
|
||||
|
||||
Recursively listing all files in a source directory:
|
||||
|
||||
```
|
||||
<list_files>
|
||||
<path>src</path>
|
||||
<recursive>true</recursive>
|
||||
</list_files>
|
||||
```
|
||||
|
||||
Examining a specific project subdirectory:
|
||||
|
||||
```
|
||||
<list_files>
|
||||
<path>src/components</path>
|
||||
<recursive>false</recursive>
|
||||
</list_files>
|
||||
```
|
||||
171
apps/docs/docs/advanced-usage/available-tools/new-task.md
Normal file
171
apps/docs/docs/advanced-usage/available-tools/new-task.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
---
|
||||
description: Discover how the new_task tool enables complex workflow management by creating subtasks with different modes, maintaining parent-child relationships for organized development.
|
||||
keywords:
|
||||
- new_task
|
||||
- Roo Code tools
|
||||
- subtasks
|
||||
- workflow management
|
||||
- task hierarchy
|
||||
- mode switching
|
||||
- complex projects
|
||||
- task organization
|
||||
- VS Code AI
|
||||
---
|
||||
|
||||
# new_task
|
||||
|
||||
The `new_task` tool creates subtasks with specialized modes while maintaining a parent-child relationship. It breaks down complex projects into manageable pieces, each operating in the mode best suited for specific work.
|
||||
|
||||
---
|
||||
|
||||
## Parameters
|
||||
|
||||
The tool accepts these parameters:
|
||||
|
||||
- `mode` (required): The slug of the mode to start the new task in (e.g., "code", "ask", "architect")
|
||||
- `message` (required): The initial user message or instructions for this new task
|
||||
- `todos` (optional): Initial todo list in markdown checklist format
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
This tool creates a new task instance with a specified starting mode and initial message. It allows complex workflows to be divided into subtasks with their own conversation history. Parent tasks are paused during subtask execution and resumed when the subtask completes, with results transferred back to the parent.
|
||||
|
||||
---
|
||||
|
||||
## When is it used?
|
||||
|
||||
- When breaking down complex projects into separate, focused subtasks
|
||||
- When different aspects of a task require different specialized modes
|
||||
- When different phases of work benefit from context separation
|
||||
- When organizing multi-phase development workflows
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- Creates subtasks with their own conversation history and specialized mode
|
||||
- Pauses parent tasks for later resumption
|
||||
- Maintains hierarchical task relationships for navigation
|
||||
- Transfers results back to parent tasks upon completion
|
||||
- Supports workflow segregation for complex projects
|
||||
- Allows different parts of a project to use modes optimized for specific work
|
||||
- Requires explicit user approval for task creation
|
||||
- Provides clear task transition in the UI
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot create tasks with modes that don't exist
|
||||
- Requires user approval before creating each new task
|
||||
- Task interface may become complex with deeply nested subtasks
|
||||
- Subtasks inherit certain workspace and extension configurations from parents
|
||||
- May require re-establishing context when switching between deeply nested tasks
|
||||
- Task completion needs explicit signaling to properly return to parent tasks
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
When the `new_task` tool is invoked, it follows this process:
|
||||
|
||||
1. **Parameter Validation**:
|
||||
|
||||
- Validates the required `mode` and `message` parameters
|
||||
- Verifies that the requested mode exists in the system
|
||||
|
||||
2. **Task Stack Management**:
|
||||
|
||||
- Maintains a task stack that tracks all active and paused tasks
|
||||
- Preserves the current mode for later resumption
|
||||
- Sets the parent task to paused state
|
||||
|
||||
3. **Task Context Management**:
|
||||
|
||||
- Creates a new task context with the provided message
|
||||
- Assigns unique taskId and instanceId identifiers for state management
|
||||
- Captures telemetry data on tool usage and task lifecycles
|
||||
|
||||
4. **Mode Switching and Integration**:
|
||||
|
||||
- Switches to the specified mode with appropriate role and capabilities
|
||||
- Initializes the new task with the provided message
|
||||
- Integrates with VS Code's command palette and code actions
|
||||
|
||||
5. **Task Completion and Result Transfer**:
|
||||
- When subtask completes, result is passed back to parent task via `finishSubTask()`
|
||||
- Parent task resumes in its original mode
|
||||
- Task history and token usage metrics are updated
|
||||
- The `taskCompleted` event is emitted with performance data
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Streamline hierarchical task planning with the optional todo list parameter for subtasks:
|
||||
|
||||
- **Pass Todo Lists**: Include predefined todo lists when creating subtasks
|
||||
- **Maintain Context**: Pass along context to the subtask in the form of a todo list
|
||||
- **Optional Enforcement**: The "New Task Require Todos" setting in VS Code can enforce todo lists for all new subtasks if desired
|
||||
|
||||
<img src="/img/v3.25.21/v3.25.21.png" alt="Subtask todo lists configuration in VS Code settings" width="600" />
|
||||
|
||||
This feature works out of the box, and you can optionally configure VS Code settings to require todos for all new tasks.
|
||||
|
||||
---
|
||||
|
||||
## Examples When Used
|
||||
|
||||
- When a front-end developer needs to architect a new feature, implement the code, and document it, they can create separate tasks for each phase with results flowing from one phase to the next.
|
||||
- When debugging an issue before implementing a fix, the debugging task can document findings that are passed to the implementation task.
|
||||
- When developing a full-stack application, database schema designs from an architect-mode task inform implementation details in a subsequent code-mode task.
|
||||
- When documenting a system after implementation, the documentation task can reference the completed implementation while using documentation-specific features.
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Creating a new task in code mode:
|
||||
|
||||
```
|
||||
<new_task>
|
||||
<mode>code</mode>
|
||||
<message>Implement a user authentication service with login, registration, and password reset functionality.</message>
|
||||
</new_task>
|
||||
```
|
||||
|
||||
Creating a documentation task after completing implementation:
|
||||
|
||||
```
|
||||
<new_task>
|
||||
<mode>docs</mode>
|
||||
<message>Create comprehensive API documentation for the authentication service we just built.</message>
|
||||
</new_task>
|
||||
```
|
||||
|
||||
Breaking down a complex feature into architectural planning and implementation:
|
||||
|
||||
```
|
||||
<new_task>
|
||||
<mode>architect</mode>
|
||||
<message>Design the database schema and system architecture for our new e-commerce platform.</message>
|
||||
</new_task>
|
||||
```
|
||||
|
||||
Creating a task with an initial todo list:
|
||||
|
||||
```
|
||||
<new_task>
|
||||
<mode>code</mode>
|
||||
<message>Build a REST API for user management</message>
|
||||
<todos>
|
||||
[ ] Set up Express server
|
||||
[ ] Create user model
|
||||
[ ] Implement CRUD endpoints
|
||||
[ ] Add authentication middleware
|
||||
[ ] Write API tests
|
||||
</todos>
|
||||
</new_task>
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue