mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
Merge branch 'main' into feature/arena-agent-collaboration
This commit is contained in:
commit
f9d4fa0a39
292 changed files with 28467 additions and 8155 deletions
|
|
@ -42,11 +42,11 @@ steps:
|
|||
args:
|
||||
- '-c'
|
||||
- |-
|
||||
export GEMINI_SANDBOX_IMAGE_TAG=$$(cat /workspace/image_tag.txt)
|
||||
echo "Using Docker image tag for build: $$GEMINI_SANDBOX_IMAGE_TAG"
|
||||
export QWEN_SANDBOX_IMAGE_TAG=$$(cat /workspace/image_tag.txt)
|
||||
echo "Using Docker image tag for build: $$QWEN_SANDBOX_IMAGE_TAG"
|
||||
npm run build:sandbox -- --output-file /workspace/final_image_uri.txt
|
||||
env:
|
||||
- 'GEMINI_SANDBOX=$_CONTAINER_TOOL'
|
||||
- 'QWEN_SANDBOX=$_CONTAINER_TOOL'
|
||||
|
||||
# Step 8: Publish sandbox container image
|
||||
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
|
||||
|
|
@ -61,7 +61,7 @@ steps:
|
|||
echo "Pushing sandbox image: $${FINAL_IMAGE_URI}"
|
||||
$_CONTAINER_TOOL push "$${FINAL_IMAGE_URI}"
|
||||
env:
|
||||
- 'GEMINI_SANDBOX=$_CONTAINER_TOOL'
|
||||
- 'QWEN_SANDBOX=$_CONTAINER_TOOL'
|
||||
|
||||
options:
|
||||
defaultLogsBucketBehavior: 'REGIONAL_USER_OWNED_BUCKET'
|
||||
|
|
|
|||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
|
@ -1,3 +1,3 @@
|
|||
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy
|
||||
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy @DragonnZhang
|
||||
# SDK TypeScript package changes require review from Mingholy
|
||||
packages/sdk-typescript/** @Mingholy
|
||||
|
|
|
|||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
|
|
@ -83,6 +83,23 @@ jobs:
|
|||
- name: 'Run sensitive keyword linter'
|
||||
run: 'node scripts/lint.js --sensitive-keywords'
|
||||
|
||||
- name: 'Build CLI package'
|
||||
run: 'npm run build --workspace=packages/cli'
|
||||
|
||||
- name: 'Generate settings schema'
|
||||
run: 'npm run generate:settings-schema'
|
||||
|
||||
- name: 'Check settings schema is up-to-date'
|
||||
run: |
|
||||
if [[ -n $(git status --porcelain packages/vscode-ide-companion/schemas/settings.schema.json) ]]; then
|
||||
echo "❌ Error: settings.schema.json is out of date!"
|
||||
echo " Please run: npm run generate:settings-schema"
|
||||
echo " Then commit the updated schema file."
|
||||
git diff packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Settings schema is up-to-date"
|
||||
|
||||
#
|
||||
# Test: Node
|
||||
#
|
||||
|
|
|
|||
12
.github/workflows/release-vscode-companion.yml
vendored
12
.github/workflows/release-vscode-companion.yml
vendored
|
|
@ -223,7 +223,7 @@ jobs:
|
|||
npm --workspace=qwen-code-vscode-ide-companion run prepackage
|
||||
|
||||
- name: 'Package VSIX (platform-specific)'
|
||||
if: '${{ matrix.target != '''' }}'
|
||||
if: "${{ matrix.target != '' }}"
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |-
|
||||
if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then
|
||||
|
|
@ -236,7 +236,7 @@ jobs:
|
|||
shell: 'bash'
|
||||
|
||||
- name: 'Package VSIX (universal)'
|
||||
if: '${{ matrix.target == '''' }}'
|
||||
if: "${{ matrix.target == '' }}"
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |-
|
||||
if [[ "${{ needs.prepare.outputs.is_preview }}" == "true" ]]; then
|
||||
|
|
@ -251,7 +251,7 @@ jobs:
|
|||
- name: 'Upload VSIX Artifact'
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'vsix-${{ matrix.target || ''universal'' }}'
|
||||
name: "vsix-${{ matrix.target || 'universal' }}"
|
||||
path: 'qwen-code-vscode-companion-${{ needs.prepare.outputs.release_version }}-*.vsix'
|
||||
if-no-files-found: 'error'
|
||||
|
||||
|
|
@ -292,7 +292,7 @@ jobs:
|
|||
npm install -g ovsx
|
||||
|
||||
- name: 'Publish to Microsoft Marketplace'
|
||||
if: '${{ needs.prepare.outputs.is_dry_run == ''false'' && needs.prepare.outputs.is_preview != ''true'' }}'
|
||||
if: "${{ needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.is_preview != 'true' }}"
|
||||
env:
|
||||
VSCE_PAT: '${{ secrets.VSCE_PAT }}'
|
||||
run: |-
|
||||
|
|
@ -303,7 +303,7 @@ jobs:
|
|||
done
|
||||
|
||||
- name: 'Publish to OpenVSX'
|
||||
if: '${{ needs.prepare.outputs.is_dry_run == ''false'' }}'
|
||||
if: "${{ needs.prepare.outputs.is_dry_run == 'false' }}"
|
||||
env:
|
||||
OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
|
||||
run: |-
|
||||
|
|
@ -318,7 +318,7 @@ jobs:
|
|||
done
|
||||
|
||||
- name: 'Upload all VSIXes as release artifacts (dry run)'
|
||||
if: '${{ needs.prepare.outputs.is_dry_run == ''true'' }}'
|
||||
if: "${{ needs.prepare.outputs.is_dry_run == 'true' }}"
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'all-vsix-packages-${{ needs.prepare.outputs.release_version }}'
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -24,6 +24,8 @@ package-lock.json
|
|||
*.iml
|
||||
.cursor
|
||||
.qoder
|
||||
.claude
|
||||
CLAUDE.md
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -18,4 +18,5 @@ eslint.config.js
|
|||
gha-creds-*.json
|
||||
junit.xml
|
||||
Thumbs.db
|
||||
packages/vscode-ide-companion/schemas/settings.schema.json
|
||||
packages/cli/src/services/insight/templates/insightTemplate.ts
|
||||
|
|
|
|||
25
.qwen/commands/qc/code-review.md
Normal file
25
.qwen/commands/qc/code-review.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
description: Code review a pull request
|
||||
---
|
||||
|
||||
You are an expert code reviewer. Follow these steps:
|
||||
|
||||
1. If no PR number is provided in the args, use Bash(\"gh pr list\") to show open PRs
|
||||
2. If a PR number is provided, use Bash(\"gh pr view <number>\") to get PR details
|
||||
3. Use Bash(\"gh pr diff <number>\") to get the diff
|
||||
4. Analyze the changes and provide a thorough code review that includes:
|
||||
- Overview of what the PR does
|
||||
- Analysis of code quality and style
|
||||
- Specific suggestions for improvements
|
||||
- Any potential issues or risks
|
||||
|
||||
Keep your review concise but thorough. Focus on:
|
||||
- Code correctness
|
||||
- Following project conventions
|
||||
- Performance implications
|
||||
- Test coverage
|
||||
- Security considerations
|
||||
|
||||
Format your review with clear sections and bullet points.
|
||||
|
||||
PR number: {{args}}
|
||||
70
.qwen/commands/qc/commit.md
Normal file
70
.qwen/commands/qc/commit.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
---
|
||||
description: Commit staged changes with an AI-generated commit message and push
|
||||
---
|
||||
|
||||
# Commit and Push
|
||||
|
||||
## Overview
|
||||
Generate a clear, concise commit message based on staged changes, confirm with the user, then commit and push.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Check repository status
|
||||
- Run `git status` to check:
|
||||
- Are there any staged changes?
|
||||
- Are there unstaged changes?
|
||||
- What is the current branch?
|
||||
|
||||
### 2. Handle unstaged changes
|
||||
- If there are unstaged changes, notify the user and list them
|
||||
- Do NOT add or commit unstaged changes
|
||||
- Proceed only with staged changes
|
||||
|
||||
### 3. Review staged changes
|
||||
- Run `git diff --staged` to see all staged changes
|
||||
- Analyze the changes in depth to understand:
|
||||
- What files were modified/added/deleted
|
||||
- The nature of the changes (feature, fix, refactor, docs, etc.)
|
||||
- The scope and impact of the changes
|
||||
|
||||
### 4. Handle branch logic
|
||||
- Get current branch name with `git branch --show-current`
|
||||
- **If current branch is `main` or `master`:**
|
||||
- Generate a proper branch name based on the changes
|
||||
- Create and switch to the new branch: `git checkout -b <branch-name>`
|
||||
- **If current branch is NOT main/master:**
|
||||
- Check if branch name matches the staged changes
|
||||
- If branch name doesn't match changes, ask user:
|
||||
- "Current branch `<branch>` doesn't seem to match these changes."
|
||||
- "Options: (1) Create and switch to a new branch, (2) Commit directly on current branch"
|
||||
- Wait for user decision
|
||||
|
||||
### 5. Generate commit message
|
||||
- Types: feat, fix, docs, style, refactor, test, chore
|
||||
- Guidelines:
|
||||
- Be clear and concise
|
||||
- Reference issues if mentioned in changes
|
||||
- Include scope in parentheses when applicable (e.g., `fix(insight):`, `feat(auth):`)
|
||||
- Add bullet points for detailed changes if it addes more value, otherwise do not use bullets
|
||||
- Include a footer explaining the purpose/impact of the changes
|
||||
|
||||
**Format:**
|
||||
```
|
||||
<type>(<scope>): <short description>
|
||||
- <detail point 1> (optional)
|
||||
- <detail point 2> (optional)
|
||||
- ...
|
||||
|
||||
This <explains the why/impact of the changes>.
|
||||
```
|
||||
|
||||
### 6. Present the result and confirm with user
|
||||
- Present the generated commit message
|
||||
- Show which branch will be used
|
||||
- Ask for confirmation: "Proceed with commit and push?"
|
||||
- Wait for user approval
|
||||
|
||||
### 7. Commit and push
|
||||
- After user confirms:
|
||||
- `git commit -m "<commit-message>"`
|
||||
- `git push -u origin <branch-name>` (use `-u` for new branches)
|
||||
42
.qwen/commands/qc/create-issue.md
Normal file
42
.qwen/commands/qc/create-issue.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
description: Draft and submit a GitHub issue based on a user-provided idea
|
||||
---
|
||||
|
||||
# Create Issue
|
||||
|
||||
## Overview
|
||||
Take the user's idea or bug description, investigate the codebase to understand the full context, draft a GitHub issue for review, and submit it once approved.
|
||||
|
||||
## Input
|
||||
The user provides a brief description of a feature request or bug report: {{args}}
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Understand the request**
|
||||
- Read the user's description carefully
|
||||
- Determine whether this is a feature request or a bug report
|
||||
|
||||
2. **Investigate the codebase**
|
||||
- Search for relevant code, files, and existing behavior related to the request
|
||||
- Build a thorough understanding of how the current system works
|
||||
- Identify any related issues or prior art if mentioned
|
||||
|
||||
3. **Draft the issue**
|
||||
- Write a markdown file for the user to review
|
||||
- Use the appropriate template:
|
||||
- Feature request: follow @.github/ISSUE_TEMPLATE/feature_request.yml
|
||||
- Bug report: follow @.github/ISSUE_TEMPLATE/bug_report.yml
|
||||
- Write from the user's perspective, not as an implementation spec
|
||||
- Keep the language clear and concise, AVOID internal implementation details
|
||||
|
||||
4. **Review with user**
|
||||
- Present the draft file to the user
|
||||
- Iterate on feedback until the user is satisfied
|
||||
- Do NOT submit until the user explicitly asks to
|
||||
|
||||
5. **Submit the issue**
|
||||
- When the user confirms, create the issue using `gh issue create`
|
||||
- Apply the appropriate labels:
|
||||
- Feature request: `type/feature-request`, `status/needs-triage`
|
||||
- Bug report: `type/bug`, `status/needs-triage`
|
||||
- Report back the issue URL
|
||||
34
.qwen/commands/qc/create-pr.md
Normal file
34
.qwen/commands/qc/create-pr.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
description: Create a pull request based on staged code changes
|
||||
---
|
||||
|
||||
# Create PR
|
||||
|
||||
## Overview
|
||||
Create a well-structured pull request with proper description and title.
|
||||
|
||||
## Steps
|
||||
1. **Review staged changes**
|
||||
- Review all staged changes to understand what has been done
|
||||
- Do not touch unstaged changes
|
||||
|
||||
2. **Prepare branch**
|
||||
- Create a new branch with proper name if current branch is main
|
||||
- Ensure all changes are committed
|
||||
- Push branch to remote
|
||||
|
||||
3. **Write PR description**
|
||||
- Use PR Template below
|
||||
- Summarize changes clearly
|
||||
- Include context and motivation
|
||||
- List any breaking changes
|
||||
- Link related issues if provided, or use "No linked issues"
|
||||
- Add this line at the end of PR body: "🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)", with a line separator
|
||||
|
||||
4. **Set up PR**
|
||||
- Create PR title and body
|
||||
- Submit PR with gh command
|
||||
|
||||
## PR Template
|
||||
|
||||
@{.github/pull_request_template.md}
|
||||
|
|
@ -109,6 +109,38 @@ Supported key names: `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight`, `Enter`,
|
|||
|
||||
Auto-screenshot is triggered after the key sequence ends (when the next step is not a `key`).
|
||||
|
||||
### `streaming` — Capture During Execution
|
||||
|
||||
Capture multiple screenshots at intervals during long-running output (e.g., progress bars). Optionally generates an animated GIF.
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'Run this command: bash progress.sh',
|
||||
streaming: {
|
||||
delayMs: 7000, // Wait before first capture (skip initial waiting phase)
|
||||
intervalMs: 500, // Interval between captures
|
||||
count: 20, // Maximum number of captures
|
||||
gif: true, // Generate animated GIF (default: true, requires ffmpeg)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `delayMs` (optional): Milliseconds to wait after pressing Enter before starting captures. Useful for skipping model thinking/approval time.
|
||||
- Captures stop early if terminal output is unchanged for 3 consecutive intervals.
|
||||
- Duplicate frames (no output change) are automatically skipped.
|
||||
|
||||
**GIF prerequisite**: If the scenario uses `streaming` with GIF enabled (default), check if `ffmpeg` is installed before running. If not, ask the user whether they'd like to install it:
|
||||
|
||||
```bash
|
||||
# Check
|
||||
which ffmpeg
|
||||
|
||||
# Install (macOS)
|
||||
brew install ffmpeg
|
||||
```
|
||||
|
||||
If the user declines, the scenario still runs — GIF generation is skipped with a warning.
|
||||
|
||||
### `capture` / `captureFull` — Explicit Screenshot
|
||||
|
||||
Use as a standalone step, or override automatic naming:
|
||||
|
|
@ -178,20 +210,32 @@ This tool is commonly used for visual verification during PR reviews. For the co
|
|||
## Full ScenarioConfig Type
|
||||
|
||||
```typescript
|
||||
interface ScenarioConfig {
|
||||
name: string; // Scenario name (also used as screenshot subdirectory name)
|
||||
spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"]
|
||||
flow: FlowStep[]; // Interaction steps
|
||||
terminal?: {
|
||||
// Terminal configuration (all optional)
|
||||
cols?: number; // Number of columns, default 100
|
||||
rows?: number; // Number of rows, default 28
|
||||
theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl
|
||||
chrome?: boolean; // macOS window decorations, default true
|
||||
title?: string; // Window title, default "Terminal"
|
||||
fontSize?: number; // Font size
|
||||
cwd?: string; // Working directory (relative to config file)
|
||||
interface FlowStep {
|
||||
type?: string; // Input text
|
||||
key?: string | string[]; // Key press(es)
|
||||
capture?: string; // Viewport screenshot filename
|
||||
captureFull?: string; // Full scrollback screenshot filename
|
||||
streaming?: {
|
||||
delayMs?: number; // Delay before first capture (default: 0)
|
||||
intervalMs: number; // Interval between captures in ms
|
||||
count: number; // Maximum number of captures
|
||||
gif?: boolean; // Generate animated GIF (default: true)
|
||||
};
|
||||
outputDir?: string; // Screenshot output directory (relative to config file)
|
||||
}
|
||||
|
||||
interface ScenarioConfig {
|
||||
name: string; // Scenario name (also used as screenshot subdirectory name)
|
||||
spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"]
|
||||
flow: FlowStep[]; // Interaction steps
|
||||
terminal?: {
|
||||
cols?: number; // Number of columns, default 100
|
||||
rows?: number; // Number of rows, default 28
|
||||
theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl
|
||||
chrome?: boolean; // macOS window decorations, default true
|
||||
title?: string; // Window title, default "Terminal"
|
||||
fontSize?: number; // Font size
|
||||
cwd?: string; // Working directory (relative to config file)
|
||||
};
|
||||
outputDir?: string; // Screenshot output directory (relative to config file)
|
||||
}
|
||||
```
|
||||
|
|
|
|||
6
.vscode/launch.json
vendored
6
.vscode/launch.json
vendored
|
|
@ -14,7 +14,7 @@
|
|||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"GEMINI_SANDBOX": "false"
|
||||
"QWEN_SANDBOX": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"GEMINI_SANDBOX": "false"
|
||||
"QWEN_SANDBOX": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"env": {
|
||||
"GEMINI_SANDBOX": "false"
|
||||
"QWEN_SANDBOX": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
297
QWEN.md
Normal file
297
QWEN.md
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
# Qwen Code - Project Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Qwen Code** is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps developers understand large codebases, automate tedious work, and ship faster.
|
||||
|
||||
This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) with adaptations to better support Qwen-Coder models.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **OpenAI-compatible, OAuth free tier**: Use an OpenAI-compatible API, or sign in with Qwen OAuth to get 1,000 free requests/day
|
||||
- **Agentic workflow, feature-rich**: Rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow
|
||||
- **Terminal-first, IDE-friendly**: Built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Language**: TypeScript 5.3+
|
||||
- **Package Manager**: npm with workspaces
|
||||
- **Build Tool**: esbuild
|
||||
- **Testing**: Vitest
|
||||
- **Linting**: ESLint + Prettier
|
||||
- **UI Framework**: Ink (React for CLI)
|
||||
- **React Version**: 19.x
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── packages/
|
||||
│ ├── cli/ # Command-line interface (main entry point)
|
||||
│ ├── core/ # Core backend logic and tool implementations
|
||||
│ ├── sdk-java/ # Java SDK
|
||||
│ ├── sdk-typescript/ # TypeScript SDK
|
||||
│ ├── test-utils/ # Shared testing utilities
|
||||
│ ├── vscode-ide-companion/ # VS Code extension companion
|
||||
│ ├── webui/ # Web UI components
|
||||
│ └── zed-extension/ # Zed editor extension
|
||||
├── scripts/ # Build and utility scripts
|
||||
├── docs/ # Documentation source
|
||||
├── docs-site/ # Documentation website (Next.js)
|
||||
├── integration-tests/ # End-to-end integration tests
|
||||
└── eslint-rules/ # Custom ESLint rules
|
||||
```
|
||||
|
||||
### Package Details
|
||||
|
||||
#### `@qwen-code/qwen-code` (packages/cli/)
|
||||
|
||||
The main CLI package providing:
|
||||
|
||||
- Interactive terminal UI using Ink/React
|
||||
- Non-interactive/headless mode
|
||||
- Authentication handling (OAuth, API keys)
|
||||
- Configuration management
|
||||
- Command system (`/help`, `/clear`, `/compress`, etc.)
|
||||
|
||||
#### `@qwen-code/qwen-code-core` (packages/core/)
|
||||
|
||||
Core library containing:
|
||||
|
||||
- **Tools**: File operations (read, write, edit, glob, grep), shell execution, web fetch, LSP integration, MCP client
|
||||
- **Subagents**: Task delegation to specialized agents
|
||||
- **Skills**: Reusable skill system
|
||||
- **Models**: Model configuration and registry for Qwen and OpenAI-compatible APIs
|
||||
- **Services**: Git integration, file discovery, session management
|
||||
- **LSP Support**: Language Server Protocol integration
|
||||
- **MCP**: Model Context Protocol implementation
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: ~20.19.0 for development (use nvm to manage versions)
|
||||
- **Git**
|
||||
- For sandboxing: Docker or Podman (optional but recommended)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
git clone https://github.com/QwenLM/qwen-code.git
|
||||
cd qwen-code
|
||||
npm install
|
||||
```
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Build all packages
|
||||
npm run build
|
||||
|
||||
# Build everything including sandbox and VSCode companion
|
||||
npm run build:all
|
||||
|
||||
# Build only packages
|
||||
npm run build:packages
|
||||
|
||||
# Development mode with hot reload
|
||||
npm run dev
|
||||
|
||||
# Bundle for distribution
|
||||
npm run bundle
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Start interactive CLI
|
||||
npm start
|
||||
|
||||
# Or after global installation
|
||||
qwen
|
||||
|
||||
# Debug mode
|
||||
npm run debug
|
||||
|
||||
# With environment variables
|
||||
DEBUG=1 npm start
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all unit tests
|
||||
npm run test
|
||||
|
||||
# Run integration tests (no sandbox)
|
||||
npm run test:e2e
|
||||
|
||||
# Run all integration tests with different sandbox modes
|
||||
npm run test:integration:all
|
||||
|
||||
# Terminal benchmark tests
|
||||
npm run test:terminal-bench
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Run all checks (lint, format, build, test)
|
||||
npm run preflight
|
||||
|
||||
# Lint only
|
||||
npm run lint
|
||||
npm run lint:fix
|
||||
|
||||
# Format only
|
||||
npm run format
|
||||
|
||||
# Type check
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
- **Strict TypeScript**: All strict flags enabled (`strictNullChecks`, `noImplicitAny`, etc.)
|
||||
- **Module System**: ES modules (`"type": "module"`)
|
||||
- **Import Style**: Node.js native ESM with `.js` extensions in imports
|
||||
- **No Relative Imports Between Packages**: ESLint enforces this restriction
|
||||
|
||||
### Key Configuration Files
|
||||
|
||||
- `tsconfig.json`: Base TypeScript configuration with strict settings
|
||||
- `eslint.config.js`: ESLint flat config with custom rules
|
||||
- `esbuild.config.js`: Build configuration
|
||||
- `vitest.config.ts`: Test configuration
|
||||
|
||||
### Import Patterns
|
||||
|
||||
```typescript
|
||||
// Within a package - use relative paths
|
||||
import { something } from './utils/something.js';
|
||||
|
||||
// Between packages - use package names
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
```
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
- Unit tests co-located with source files (`.test.ts` suffix)
|
||||
- Integration tests in separate `integration-tests/` directory
|
||||
- Uses Vitest with globals enabled
|
||||
- Mocking via `msw` for HTTP, `memfs`/`mock-fs` for filesystem
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
#### Tools System
|
||||
|
||||
All tools extend `BaseDeclarativeTool` or implement the tool interfaces:
|
||||
|
||||
- Located in `packages/core/src/tools/`
|
||||
- Each tool has a corresponding `.test.ts` file
|
||||
- Tools are registered in the tool registry
|
||||
|
||||
#### Subagents System
|
||||
|
||||
Task delegation framework:
|
||||
|
||||
- Configuration stored as Markdown + YAML frontmatter
|
||||
- Supports both project-level and user-level subagents
|
||||
- Event-driven architecture for UI updates
|
||||
|
||||
#### Configuration System
|
||||
|
||||
Hierarchical configuration loading:
|
||||
|
||||
1. Default values
|
||||
2. User settings (`~/.qwen/settings.json`)
|
||||
3. Project settings (`.qwen/settings.json`)
|
||||
4. Environment variables
|
||||
5. CLI flags
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
1. **Qwen OAuth** (recommended): Browser-based OAuth flow
|
||||
2. **OpenAI-compatible API**: Via `OPENAI_API_KEY` environment variable
|
||||
|
||||
Environment variables for API mode:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-api-key"
|
||||
export OPENAI_BASE_URL="https://api.openai.com/v1" # optional
|
||||
export OPENAI_MODEL="gpt-4o" # optional
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### VS Code
|
||||
|
||||
Press `F5` to launch with debugger attached, or:
|
||||
|
||||
```bash
|
||||
npm run debug # Runs with --inspect-brk
|
||||
```
|
||||
|
||||
### React DevTools (for CLI UI)
|
||||
|
||||
```bash
|
||||
DEV=true npm start
|
||||
npx react-devtools@4.28.5
|
||||
```
|
||||
|
||||
### Sandbox Debugging
|
||||
|
||||
```bash
|
||||
DEBUG=1 qwen
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- User documentation: <https://qwenlm.github.io/qwen-code-docs/>
|
||||
- Local docs development:
|
||||
|
||||
```bash
|
||||
cd docs-site
|
||||
npm install
|
||||
npm run link # Links ../docs to content
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed guidelines. Key points:
|
||||
|
||||
1. Link PRs to existing issues
|
||||
2. Keep PRs small and focused
|
||||
3. Use Draft PRs for WIP
|
||||
4. Ensure `npm run preflight` passes
|
||||
5. Update documentation for user-facing changes
|
||||
6. Follow Conventional Commits for commit messages
|
||||
|
||||
## Useful Commands Reference
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| `npm start` | Start CLI in interactive mode |
|
||||
| `npm run dev` | Development mode with hot reload |
|
||||
| `npm run build` | Build all packages |
|
||||
| `npm run test` | Run unit tests |
|
||||
| `npm run test:e2e` | Run integration tests |
|
||||
| `npm run preflight` | Full CI check (clean, install, format, lint, build, typecheck, test) |
|
||||
| `npm run lint` | Run ESLint |
|
||||
| `npm run format` | Run Prettier |
|
||||
| `npm run clean` | Clean build artifacts |
|
||||
|
||||
## Session Commands (within CLI)
|
||||
|
||||
- `/help` - Display available commands
|
||||
- `/clear` - Clear conversation history
|
||||
- `/compress` - Compress history to save tokens
|
||||
- `/stats` - Show session information
|
||||
- `/bug` - Submit bug report
|
||||
- `/exit` or `/quit` - Exit Qwen Code
|
||||
|
||||
---
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Example Proxy Script
|
||||
|
||||
The following is an example of a proxy script that can be used with the `GEMINI_SANDBOX_PROXY_COMMAND` environment variable. This script only allows `HTTPS` connections to `example.com:443` and declines all other requests.
|
||||
The following is an example of a proxy script that can be used with the `QWEN_SANDBOX_PROXY_COMMAND` environment variable. This script only allows `HTTPS` connections to `example.com:443` and declines all other requests.
|
||||
|
||||
```javascript
|
||||
#!/usr/bin/env node
|
||||
|
|
@ -12,7 +12,7 @@ The following is an example of a proxy script that can be used with the `GEMINI_
|
|||
*/
|
||||
|
||||
// Example proxy server that listens on :::8877 and only allows HTTPS connections to example.com.
|
||||
// Set `GEMINI_SANDBOX_PROXY_COMMAND=scripts/example-proxy.js` to run proxy alongside sandbox
|
||||
// Set `QWEN_SANDBOX_PROXY_COMMAND=scripts/example-proxy.js` to run proxy alongside sandbox
|
||||
// Test via `curl https://example.com` inside sandbox (in shell mode or via shell tool)
|
||||
|
||||
import http from 'node:http';
|
||||
|
|
|
|||
|
|
@ -834,23 +834,25 @@ qwen mcp add --transport sse sse-server https://api.example.com/sse/
|
|||
qwen mcp add --transport sse secure-sse https://api.example.com/sse/ --header "Authorization: Bearer abc123"
|
||||
```
|
||||
|
||||
### Listing Servers (`qwen mcp list`)
|
||||
### Managing Servers (`qwen mcp`)
|
||||
|
||||
To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status.
|
||||
To view and manage all MCP servers currently configured, use the `manage` command or simply `qwen mcp`. This opens an interactive TUI dialog where you can:
|
||||
|
||||
- View all MCP servers with their connection status
|
||||
- Enable/disable servers
|
||||
- Reconnect to disconnected servers
|
||||
- View tools and prompts provided by each server
|
||||
- View server logs
|
||||
|
||||
**Command:**
|
||||
|
||||
```bash
|
||||
qwen mcp list
|
||||
qwen mcp
|
||||
# or
|
||||
qwen mcp manage
|
||||
```
|
||||
|
||||
**Example Output:**
|
||||
|
||||
```sh
|
||||
✓ stdio-server: command: python3 server.py (stdio) - Connected
|
||||
✓ http-server: https://api.example.com/mcp (http) - Connected
|
||||
✗ sse-server: https://api.example.com/sse (sse) - Disconnected
|
||||
```
|
||||
The management dialog provides a visual interface showing each server's name, configuration details, connection status, and available tools/prompts.
|
||||
|
||||
### Removing a Server (`qwen mcp remove`)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ RUN apt-get update && apt-get install -y \
|
|||
#### 4、Create the first sandbox image under the root directory of your project
|
||||
|
||||
```bash
|
||||
GEMINI_SANDBOX=docker BUILD_SANDBOX=1 qwen -s
|
||||
QWEN_SANDBOX=docker BUILD_SANDBOX=1 qwen -s
|
||||
# Observe whether the sandbox version of the tool you launched is consistent with the version of your custom image. If they are consistent, the startup will be successful
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -391,22 +391,22 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe
|
|||
|
||||
### Environment Variables Table
|
||||
|
||||
| Variable | Description | Notes |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GEMINI_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. |
|
||||
| `GEMINI_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. |
|
||||
| `GEMINI_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. |
|
||||
| `GEMINI_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. |
|
||||
| `GEMINI_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. |
|
||||
| `GEMINI_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. |
|
||||
| `GEMINI_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. |
|
||||
| `GEMINI_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. |
|
||||
| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). |
|
||||
| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. |
|
||||
| `NO_COLOR` | Set to any value to disable all color output in the CLI. | |
|
||||
| `CLI_TITLE` | Set to a string to customize the title of the CLI. | |
|
||||
| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. |
|
||||
| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` |
|
||||
| Variable | Description | Notes |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. |
|
||||
| `QWEN_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. |
|
||||
| `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. |
|
||||
| `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. |
|
||||
| `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. |
|
||||
| `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. |
|
||||
| `QWEN_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. |
|
||||
| `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. |
|
||||
| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). |
|
||||
| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. |
|
||||
| `NO_COLOR` | Set to any value to disable all color output in the CLI. | |
|
||||
| `CLI_TITLE` | Set to a string to customize the title of the CLI. | |
|
||||
| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. |
|
||||
| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` |
|
||||
|
||||
## Command-Line Arguments
|
||||
|
||||
|
|
@ -509,7 +509,7 @@ Qwen Code can execute potentially unsafe operations (like shell commands and fil
|
|||
[Sandbox](../features/sandbox) is disabled by default, but you can enable it in a few ways:
|
||||
|
||||
- Using `--sandbox` or `-s` flag.
|
||||
- Setting `GEMINI_SANDBOX` environment variable.
|
||||
- Setting `QWEN_SANDBOX` environment variable.
|
||||
- Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default.
|
||||
|
||||
By default, it uses a pre-built `qwen-code-sandbox` Docker image.
|
||||
|
|
|
|||
|
|
@ -12,17 +12,11 @@ We offer a suite of extension management tools using both `qwen extensions` CLI
|
|||
|
||||
You can manage extensions at runtime within the interactive CLI using `/extensions` slash commands. These commands support hot-reloading, meaning changes take effect immediately without restarting the application.
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `/extensions` or `/extensions list` | List all installed extensions with their status |
|
||||
| `/extensions install <source>` | Install an extension from a git URL, local path, or marketplace |
|
||||
| `/extensions uninstall <name>` | Uninstall an extension |
|
||||
| `/extensions enable <name> --scope <user\|workspace>` | Enable an extension |
|
||||
| `/extensions disable <name> --scope <user\|workspace>` | Disable an extension |
|
||||
| `/extensions update <name>` | Update a specific extension |
|
||||
| `/extensions update --all` | Update all extensions with available updates |
|
||||
| `/extensions detail <name>` | Show details of an extension |
|
||||
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |
|
||||
| Command | Description |
|
||||
| ------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `/extensions` or `/extensions manage` | Manage all installed extensions |
|
||||
| `/extensions install <source>` | Install an extension from a git URL, local path, or marketplace |
|
||||
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |
|
||||
|
||||
### CLI Extension Management
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,10 @@ Qwen Code loads MCP servers from `mcpServers` in your `settings.json`. You can c
|
|||
qwen mcp add --transport http my-server http://localhost:3000/mcp
|
||||
```
|
||||
|
||||
2. Verify it shows up:
|
||||
2. Open MCP management dialog to view and manage servers:
|
||||
|
||||
```bash
|
||||
qwen mcp list
|
||||
qwen mcp
|
||||
```
|
||||
|
||||
3. Restart Qwen Code in the same project (or start it if it wasn’t running yet), then ask the model to use tools from that server.
|
||||
|
|
@ -274,12 +274,6 @@ qwen mcp add [options] <name> <commandOrUrl> [args...]
|
|||
| `--include-tools` | A comma-separated list of tools to include. | all tools included | `--include-tools mytool,othertool` |
|
||||
| `--exclude-tools` | A comma-separated list of tools to exclude. | none | `--exclude-tools mytool` |
|
||||
|
||||
#### Listing servers (`qwen mcp list`)
|
||||
|
||||
```bash
|
||||
qwen mcp list
|
||||
```
|
||||
|
||||
#### Removing a server (`qwen mcp remove`)
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ The benefits of sandboxing include:
|
|||
|
||||
> [!note]
|
||||
>
|
||||
> **Naming note:** Some sandbox-related environment variables still use the `GEMINI_*` prefix for backwards compatibility.
|
||||
> **Naming note:** Some sandbox-related environment variables may have used the `GEMINI_*` prefix historically. All new environment variables use the `QWEN_*` prefix.
|
||||
|
||||
## Sandboxing methods
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ The container sandbox mounts your workspace and your `~/.qwen` directory into th
|
|||
qwen -s -p "analyze the code structure"
|
||||
|
||||
# Or enable sandboxing for your shell session (recommended for CI / scripts)
|
||||
export GEMINI_SANDBOX=true # true auto-picks a provider (see notes below)
|
||||
export QWEN_SANDBOX=true # true auto-picks a provider (see notes below)
|
||||
qwen -p "run the test suite"
|
||||
|
||||
# Configure in settings.json
|
||||
|
|
@ -83,26 +83,26 @@ qwen -p "run the test suite"
|
|||
>
|
||||
> **Provider selection notes:**
|
||||
>
|
||||
> - On **macOS**, `GEMINI_SANDBOX=true` typically selects `sandbox-exec` (Seatbelt) if available.
|
||||
> - On **Linux/Windows**, `GEMINI_SANDBOX=true` requires `docker` or `podman` to be installed.
|
||||
> - To force a provider, set `GEMINI_SANDBOX=docker|podman|sandbox-exec`.
|
||||
> - On **macOS**, `QWEN_SANDBOX=true` typically selects `sandbox-exec` (Seatbelt) if available.
|
||||
> - On **Linux/Windows**, `QWEN_SANDBOX=true` requires `docker` or `podman` to be installed.
|
||||
> - To force a provider, set `QWEN_SANDBOX=docker|podman|sandbox-exec`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable sandboxing (in order of precedence)
|
||||
|
||||
1. **Environment variable**: `GEMINI_SANDBOX=true|false|docker|podman|sandbox-exec`
|
||||
1. **Environment variable**: `QWEN_SANDBOX=true|false|docker|podman|sandbox-exec`
|
||||
2. **Command flag / argument**: `-s`, `--sandbox`, or `--sandbox=<provider>`
|
||||
3. **Settings file**: `tools.sandbox` in your `settings.json` (e.g., `{"tools": {"sandbox": true}}`).
|
||||
|
||||
> [!important]
|
||||
>
|
||||
> If `GEMINI_SANDBOX` is set, it **overrides** the CLI flag and `settings.json`.
|
||||
> If `QWEN_SANDBOX` is set, it **overrides** the CLI flag and `settings.json`.
|
||||
|
||||
### Configure the sandbox image (Docker/Podman)
|
||||
|
||||
- **CLI flag**: `--sandbox-image <image>`
|
||||
- **Environment variable**: `GEMINI_SANDBOX_IMAGE=<image>`
|
||||
- **Environment variable**: `QWEN_SANDBOX_IMAGE=<image>`
|
||||
|
||||
If you don’t set either, Qwen Code uses the default image configured in the CLI package (for example `ghcr.io/qwenlm/qwen-code:<version>`).
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ export SANDBOX_FLAGS="--flag1 --flag2=value"
|
|||
|
||||
If you want to restrict outbound network access to an allowlist, you can run a local proxy alongside the sandbox:
|
||||
|
||||
- Set `GEMINI_SANDBOX_PROXY_COMMAND=<command>`
|
||||
- Set `QWEN_SANDBOX_PROXY_COMMAND=<command>`
|
||||
- The command must start a proxy server that listens on `:::8877`
|
||||
|
||||
This is especially useful with `*-proxied` Seatbelt profiles.
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import { TestRig } from './test-helper.js';
|
|||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
const INITIAL_PROMPT = 'Create a quick note (smoke test).';
|
||||
const IS_SANDBOX =
|
||||
process.env['GEMINI_SANDBOX'] &&
|
||||
process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false';
|
||||
process.env['QWEN_SANDBOX'] &&
|
||||
process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false';
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
|
|
@ -45,6 +45,7 @@ type SessionUpdateNotification = {
|
|||
text?: string;
|
||||
};
|
||||
modeId?: string;
|
||||
currentModeId?: string;
|
||||
_meta?: {
|
||||
usage?: UsageMetadata;
|
||||
};
|
||||
|
|
@ -313,7 +314,7 @@ function setupAcpTest(
|
|||
}
|
||||
});
|
||||
|
||||
it('returns modes on initialize and allows setting mode and model', async () => {
|
||||
it('initializes and allows setting mode', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp mode and model');
|
||||
|
||||
|
|
@ -326,41 +327,11 @@ function setupAcpTest(
|
|||
clientCapabilities: {
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
},
|
||||
})) as {
|
||||
protocolVersion: number;
|
||||
modes: {
|
||||
currentModeId: string;
|
||||
availableModes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
})) as { protocolVersion: number };
|
||||
|
||||
expect(initResult).toBeDefined();
|
||||
expect(initResult.protocolVersion).toBe(1);
|
||||
|
||||
// Verify modes data is present
|
||||
expect(initResult.modes).toBeDefined();
|
||||
expect(initResult.modes.currentModeId).toBeDefined();
|
||||
expect(Array.isArray(initResult.modes.availableModes)).toBe(true);
|
||||
expect(initResult.modes.availableModes.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify available modes have expected structure
|
||||
const modeIds = initResult.modes.availableModes.map((m) => m.id);
|
||||
expect(modeIds).toContain('default');
|
||||
expect(modeIds).toContain('yolo');
|
||||
expect(modeIds).toContain('auto-edit');
|
||||
expect(modeIds).toContain('plan');
|
||||
|
||||
// Verify each mode has required fields
|
||||
for (const mode of initResult.modes.availableModes) {
|
||||
expect(mode.id).toBeTruthy();
|
||||
expect(mode.name).toBeTruthy();
|
||||
expect(mode.description).toBeTruthy();
|
||||
}
|
||||
|
||||
// Test 2: Authenticate
|
||||
await sendRequest('authenticate', { methodId: 'openai' });
|
||||
|
||||
|
|
@ -381,37 +352,22 @@ function setupAcpTest(
|
|||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'yolo',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult).toBeDefined();
|
||||
expect(setModeResult.modeId).toBe('yolo');
|
||||
})) as unknown;
|
||||
expect(setModeResult).toEqual({});
|
||||
|
||||
// Test 5: Set approval mode to 'auto-edit'
|
||||
const setModeResult2 = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'auto-edit',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult2).toBeDefined();
|
||||
expect(setModeResult2.modeId).toBe('auto-edit');
|
||||
})) as unknown;
|
||||
expect(setModeResult2).toEqual({});
|
||||
|
||||
// Test 6: Set approval mode back to 'default'
|
||||
const setModeResult3 = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'default',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult3).toBeDefined();
|
||||
expect(setModeResult3.modeId).toBe('default');
|
||||
|
||||
// Test 7: Set model using openai model instead of first available model (index=0) which could be qwen-oauth requiring login
|
||||
const openaiModel = newSession.models.availableModels.find((model) =>
|
||||
model.modelId.includes('openai'),
|
||||
);
|
||||
expect(openaiModel).toBeDefined();
|
||||
const setModelResult = (await sendRequest('session/set_model', {
|
||||
sessionId: newSession.sessionId,
|
||||
modelId: openaiModel!.modelId,
|
||||
})) as { modelId: string };
|
||||
expect(setModelResult).toBeDefined();
|
||||
expect(setModelResult.modelId).toBeTruthy();
|
||||
})) as unknown;
|
||||
expect(setModeResult3).toEqual({});
|
||||
} catch (e) {
|
||||
if (stderr.length) {
|
||||
console.error('Agent stderr:', stderr.join(''));
|
||||
|
|
@ -422,7 +378,7 @@ function setupAcpTest(
|
|||
}
|
||||
});
|
||||
|
||||
it('includes authMethods in error data when auth is required', async () => {
|
||||
it('returns internal error details when model auth is required', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp auth methods in error data');
|
||||
|
||||
|
|
@ -447,18 +403,23 @@ function setupAcpTest(
|
|||
};
|
||||
};
|
||||
|
||||
// Attempt to set the first model (which might be qwen-oauth requiring login) without authenticating
|
||||
// This should trigger an auth error with authMethods in the response
|
||||
const firstModel = newSession.models.availableModels[0];
|
||||
// Choose a qwen-oauth model to trigger auth-required path deterministically.
|
||||
const qwenOauthModel = newSession.models.availableModels.find((model) =>
|
||||
model.modelId.includes('qwen-oauth'),
|
||||
);
|
||||
expect(qwenOauthModel).toBeDefined();
|
||||
await expect(
|
||||
sendRequest('session/set_model', {
|
||||
sendRequest('session/set_config_option', {
|
||||
sessionId: newSession.sessionId,
|
||||
modelId: firstModel.modelId,
|
||||
configId: 'model',
|
||||
value: qwenOauthModel!.modelId,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
response: {
|
||||
code: -32603,
|
||||
message: 'Internal error',
|
||||
data: {
|
||||
authMethods: expect.any(Array),
|
||||
details: expect.any(String),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -606,10 +567,7 @@ function setupAcpTest(
|
|||
).rejects.toMatchObject({
|
||||
response: {
|
||||
code: -32602,
|
||||
message: 'Invalid params',
|
||||
data: {
|
||||
details: 'Unsupported configId: invalid_config',
|
||||
},
|
||||
message: 'Invalid params: Unsupported configId: invalid_config',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
@ -726,8 +684,8 @@ function setupAcpTest(
|
|||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'plan',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult.modeId).toBe('plan');
|
||||
})) as unknown;
|
||||
expect(setModeResult).toEqual({});
|
||||
|
||||
// Send a prompt that should trigger the LLM to call exit_plan_mode
|
||||
// The prompt is designed to trigger planning behavior
|
||||
|
|
@ -780,9 +738,9 @@ function setupAcpTest(
|
|||
// Verify mode update structure
|
||||
const modeUpdate = modeUpdateNotifications[0];
|
||||
expect(modeUpdate.sessionId).toBe(newSession.sessionId);
|
||||
expect(modeUpdate.update?.modeId).toBeDefined();
|
||||
expect(modeUpdate.update?.currentModeId).toBeDefined();
|
||||
// Mode should be auto-edit since we approved with proceed_always
|
||||
expect(modeUpdate.update?.modeId).toBe('auto-edit');
|
||||
expect(modeUpdate.update?.currentModeId).toBe('auto-edit');
|
||||
}
|
||||
|
||||
// Note: If the LLM didn't call exit_plan_mode, that's acceptable
|
||||
|
|
@ -834,8 +792,8 @@ function setupAcpTest(
|
|||
const setModeResult = (await sendRequest('session/set_mode', {
|
||||
sessionId: newSession.sessionId,
|
||||
modeId: 'plan',
|
||||
})) as { modeId: string };
|
||||
expect(setModeResult.modeId).toBe('plan');
|
||||
})) as unknown;
|
||||
expect(setModeResult).toEqual({});
|
||||
|
||||
// Try to create a file - this should be blocked by plan mode
|
||||
const promptResult = await sendRequest('session/prompt', {
|
||||
|
|
|
|||
|
|
@ -1,277 +1,291 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Qwen Code Chat Export</title>
|
||||
<!-- Load React and ReactDOM from CDN -->
|
||||
<script
|
||||
crossorigin
|
||||
src="https://unpkg.com/react@18/umd/react.production.min.js"
|
||||
></script>
|
||||
<script
|
||||
crossorigin
|
||||
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
|
||||
></script>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Qwen Code Chat Export</title>
|
||||
<!-- Load React and ReactDOM from CDN -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<!-- Manually create the jsxRuntime object to satisfy the dependency -->
|
||||
<script>
|
||||
// Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
|
||||
const withKey = (props, key) =>
|
||||
key == null ? props : Object.assign({}, props, { key });
|
||||
const jsx = (type, props, key) =>
|
||||
React.createElement(type, withKey(props, key));
|
||||
const jsxRuntime = {
|
||||
Fragment: React.Fragment,
|
||||
jsx,
|
||||
jsxs: jsx,
|
||||
jsxDEV: jsx,
|
||||
};
|
||||
|
||||
<!-- Manually create the jsxRuntime object to satisfy the dependency -->
|
||||
<script>
|
||||
// Provide a minimal JSX runtime for builds that expect react/jsx-runtime globals.
|
||||
const withKey = (props, key) =>
|
||||
key == null ? props : Object.assign({}, props, { key });
|
||||
const jsx = (type, props, key) => React.createElement(type, withKey(props, key));
|
||||
const jsxRuntime = {
|
||||
Fragment: React.Fragment,
|
||||
jsx,
|
||||
jsxs: jsx,
|
||||
jsxDEV: jsx
|
||||
};
|
||||
window.ReactJSXRuntime = jsxRuntime;
|
||||
window['react/jsx-runtime'] = jsxRuntime;
|
||||
window['react/jsx-dev-runtime'] = jsxRuntime;
|
||||
</script>
|
||||
|
||||
window.ReactJSXRuntime = jsxRuntime;
|
||||
window['react/jsx-runtime'] = jsxRuntime;
|
||||
window['react/jsx-dev-runtime'] = jsxRuntime;
|
||||
</script>
|
||||
<!-- Load the webui library from CDN -->
|
||||
<script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.3/dist/index.umd.js"></script>
|
||||
|
||||
<!-- Load the webui library from CDN -->
|
||||
<script src="https://unpkg.com/@qwen-code/webui@0.1.0-beta.3/dist/index.umd.js"></script>
|
||||
<!-- Load the CSS -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.3/dist/styles.css"
|
||||
/>
|
||||
|
||||
<!-- Load the CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/@qwen-code/webui@0.1.0-beta.3/dist/styles.css">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #18181b;
|
||||
--bg-secondary: #27272a;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1aa;
|
||||
--border-color: #3f3f46;
|
||||
--accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #18181b;
|
||||
--bg-secondary: #27272a;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1aa;
|
||||
--border-color: #3f3f46;
|
||||
--accent-color: #3b82f6;
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: rgba(24, 24, 27, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 40px 20px;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
max-width: 100%;
|
||||
padding: 20px 16px;
|
||||
.page-wrapper {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: rgba(24, 24, 27, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #71717a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-container {
|
||||
padding: 16px 12px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 40px 20px;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page-wrapper">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>Qwen Code Export</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Session Id</span>
|
||||
<span id="session-id" class="font-mono">-</span>
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg-primary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
max-width: 100%;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.meta {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chat-container {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="page-wrapper">
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>Qwen Code Export</h1>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Export Time</span>
|
||||
<span id="session-date">-</span>
|
||||
<div class="meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Session Id</span>
|
||||
<span id="session-id" class="font-mono">-</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Export Time</span>
|
||||
<span id="session-date">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-root-no-babel" class="chat-container"></div>
|
||||
</div>
|
||||
|
||||
<div id="chat-root-no-babel" class="chat-container"></div>
|
||||
</div>
|
||||
<script id="chat-data" type="application/json"></script>
|
||||
|
||||
<script id="chat-data" type="application/json">
|
||||
<script>
|
||||
const chatDataElement = document.getElementById('chat-data');
|
||||
const chatData = chatDataElement?.textContent
|
||||
? JSON.parse(chatDataElement.textContent)
|
||||
: {};
|
||||
const rawMessages = Array.isArray(chatData.messages)
|
||||
? chatData.messages
|
||||
: [];
|
||||
const messages = rawMessages.filter(
|
||||
(record) => record && record.type !== 'system',
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const chatDataElement = document.getElementById('chat-data');
|
||||
const chatData = chatDataElement?.textContent
|
||||
? JSON.parse(chatDataElement.textContent)
|
||||
: {};
|
||||
const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : [];
|
||||
const messages = rawMessages.filter((record) => record && record.type !== 'system');
|
||||
|
||||
// Populate metadata
|
||||
const sessionIdElement = document.getElementById('session-id');
|
||||
if (sessionIdElement && chatData.sessionId) {
|
||||
sessionIdElement.textContent = chatData.sessionId;
|
||||
}
|
||||
|
||||
const sessionDateElement = document.getElementById('session-date');
|
||||
if (sessionDateElement && chatData.startTime) {
|
||||
try {
|
||||
const date = new Date(chatData.startTime);
|
||||
sessionDateElement.textContent = date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
sessionDateElement.textContent = chatData.startTime;
|
||||
// Populate metadata
|
||||
const sessionIdElement = document.getElementById('session-id');
|
||||
if (sessionIdElement && chatData.sessionId) {
|
||||
sessionIdElement.textContent = chatData.sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the ChatViewer and Platform components from the global object
|
||||
const { ChatViewer, PlatformProvider } = QwenCodeWebUI;
|
||||
|
||||
// Define a minimal platform context for web usage
|
||||
const platformContext = {
|
||||
platform: 'web',
|
||||
postMessage: (message) => {
|
||||
console.log('Posted message:', message);
|
||||
},
|
||||
onMessage: (handler) => {
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
},
|
||||
openFile: (path) => {
|
||||
console.log('Opening file:', path);
|
||||
},
|
||||
getResourceUrl: (resource) => {
|
||||
return null;
|
||||
},
|
||||
features: {
|
||||
canOpenFile: false,
|
||||
canCopy: true
|
||||
const sessionDateElement = document.getElementById('session-date');
|
||||
if (sessionDateElement && chatData.startTime) {
|
||||
try {
|
||||
const date = new Date(chatData.startTime);
|
||||
sessionDateElement.textContent = date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (e) {
|
||||
sessionDateElement.textContent = chatData.startTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Render the ChatViewer component without Babel
|
||||
const rootElementNoBabel = document.getElementById('chat-root-no-babel');
|
||||
// Get the ChatViewer and Platform components from the global object
|
||||
const { ChatViewer, PlatformProvider } = QwenCodeWebUI;
|
||||
|
||||
// Create the ChatViewer element wrapped with PlatformProvider using React.createElement (no JSX)
|
||||
const ChatAppNoBabel = React.createElement(PlatformProvider, { value: platformContext },
|
||||
React.createElement(ChatViewer, {
|
||||
messages,
|
||||
autoScroll: false,
|
||||
theme: 'dark'
|
||||
})
|
||||
);
|
||||
// Define a minimal platform context for web usage
|
||||
const platformContext = {
|
||||
platform: 'web',
|
||||
postMessage: (message) => {
|
||||
console.log('Posted message:', message);
|
||||
},
|
||||
onMessage: (handler) => {
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
},
|
||||
openFile: (path) => {
|
||||
console.log('Opening file:', path);
|
||||
},
|
||||
getResourceUrl: (resource) => {
|
||||
return null;
|
||||
},
|
||||
features: {
|
||||
canOpenFile: false,
|
||||
canCopy: true,
|
||||
},
|
||||
};
|
||||
|
||||
ReactDOM.render(ChatAppNoBabel, rootElementNoBabel);
|
||||
</script>
|
||||
</body>
|
||||
// Render the ChatViewer component without Babel
|
||||
const rootElementNoBabel = document.getElementById('chat-root-no-babel');
|
||||
|
||||
// Create the ChatViewer element wrapped with PlatformProvider using React.createElement (no JSX)
|
||||
const ChatAppNoBabel = React.createElement(
|
||||
PlatformProvider,
|
||||
{ value: platformContext },
|
||||
React.createElement(ChatViewer, {
|
||||
messages,
|
||||
autoScroll: false,
|
||||
theme: 'dark',
|
||||
}),
|
||||
);
|
||||
|
||||
ReactDOM.render(ChatAppNoBabel, rootElementNoBabel);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
189
integration-tests/fixtures/settings-migration/workspaces.json
Normal file
189
integration-tests/fixtures/settings-migration/workspaces.json
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
{
|
||||
"v1Settings": {
|
||||
"theme": "dark",
|
||||
"model": "gemini",
|
||||
"autoAccept": true,
|
||||
"hideTips": false,
|
||||
"vimMode": true,
|
||||
"checkpointing": true,
|
||||
"disableAutoUpdate": true,
|
||||
"disableLoadingPhrases": true,
|
||||
"mcpServers": {
|
||||
"fetch": {
|
||||
"command": "node",
|
||||
"args": ["fetch-server.js"]
|
||||
}
|
||||
},
|
||||
"customUserSetting": "preserved-value"
|
||||
},
|
||||
"v1ComplexSettings": {
|
||||
"theme": "dark",
|
||||
"model": "gemini-1.5-pro",
|
||||
"autoAccept": false,
|
||||
"hideTips": true,
|
||||
"vimMode": false,
|
||||
"checkpointing": true,
|
||||
"disableAutoUpdate": true,
|
||||
"disableUpdateNag": false,
|
||||
"disableLoadingPhrases": true,
|
||||
"disableFuzzySearch": false,
|
||||
"disableCacheControl": true,
|
||||
"allowedTools": ["read-file", "write-file"],
|
||||
"allowMCPServers": true,
|
||||
"autoConfigureMaxOldSpaceSize": true,
|
||||
"bugCommand": "/bug",
|
||||
"chatCompression": "auto",
|
||||
"coreTools": ["edit", "bash"],
|
||||
"customThemes": [],
|
||||
"customWittyPhrases": [],
|
||||
"fileFiltering": true,
|
||||
"folderTrust": true,
|
||||
"ideMode": true,
|
||||
"includeDirectories": ["src", "lib"],
|
||||
"maxSessionTurns": 50,
|
||||
"preferredEditor": "vscode",
|
||||
"sandbox": false,
|
||||
"summarizeToolOutput": true,
|
||||
"telemetry": {
|
||||
"enabled": false
|
||||
},
|
||||
"useRipgrep": true,
|
||||
"myCustomKey": "custom-value",
|
||||
"anotherCustomSetting": {
|
||||
"nested": true,
|
||||
"items": [1, 2, 3]
|
||||
}
|
||||
},
|
||||
"v1ArrayAndNullSettings": {
|
||||
"theme": null,
|
||||
"model": ["gemini", "claude"],
|
||||
"autoAccept": false,
|
||||
"includeDirectories": [],
|
||||
"disableFuzzySearch": "TRUE",
|
||||
"disableCacheControl": "FALSE",
|
||||
"customArray": [{ "key": 1 }]
|
||||
},
|
||||
"v1ParentCollisionSettings": {
|
||||
"theme": "dark",
|
||||
"model": "gemini",
|
||||
"ui": "legacy-ui-string",
|
||||
"general": "legacy-general-string",
|
||||
"disableAutoUpdate": true,
|
||||
"disableLoadingPhrases": false,
|
||||
"notes": {
|
||||
"fromUser": "preserve-custom"
|
||||
}
|
||||
},
|
||||
"v1VersionStringSettings": {
|
||||
"$version": "2",
|
||||
"theme": "light",
|
||||
"model": "qwen-plus",
|
||||
"disableAutoUpdate": "false",
|
||||
"disableLoadingPhrases": "TRUE",
|
||||
"ui": {
|
||||
"hideWindowTitle": true
|
||||
},
|
||||
"customSection": {
|
||||
"keepMe": true
|
||||
}
|
||||
},
|
||||
"v2Settings": {
|
||||
"$version": 2,
|
||||
"ui": {
|
||||
"theme": "light",
|
||||
"accessibility": {
|
||||
"disableLoadingPhrases": false
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"disableAutoUpdate": false,
|
||||
"disableUpdateNag": false,
|
||||
"checkpointing": false
|
||||
},
|
||||
"model": {
|
||||
"name": "claude"
|
||||
},
|
||||
"context": {
|
||||
"fileFiltering": {
|
||||
"disableFuzzySearch": true
|
||||
}
|
||||
},
|
||||
"mcpServers": {}
|
||||
},
|
||||
"v2MinimalSettings": {
|
||||
"$version": 2
|
||||
},
|
||||
"v2BooleanStringSettings": {
|
||||
"$version": 2,
|
||||
"general": {
|
||||
"disableAutoUpdate": "TRUE",
|
||||
"disableUpdateNag": "false"
|
||||
},
|
||||
"ui": {
|
||||
"accessibility": {
|
||||
"disableLoadingPhrases": "FaLsE"
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"fileFiltering": {
|
||||
"disableFuzzySearch": "TRUE"
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"disableCacheControl": "false"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v2PreexistingEnableSettings": {
|
||||
"$version": 2,
|
||||
"general": {
|
||||
"disableAutoUpdate": false,
|
||||
"disableUpdateNag": true,
|
||||
"enableAutoUpdate": true
|
||||
},
|
||||
"ui": {
|
||||
"accessibility": {
|
||||
"disableLoadingPhrases": true,
|
||||
"enableLoadingPhrases": true
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"fileFiltering": {
|
||||
"disableFuzzySearch": false,
|
||||
"enableFuzzySearch": false
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"disableCacheControl": true,
|
||||
"enableCacheControl": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"v3LegacyDisableSettings": {
|
||||
"$version": 3,
|
||||
"general": {
|
||||
"disableAutoUpdate": true,
|
||||
"enableAutoUpdate": false
|
||||
},
|
||||
"ui": {
|
||||
"accessibility": {
|
||||
"disableLoadingPhrases": false,
|
||||
"enableLoadingPhrases": true
|
||||
}
|
||||
},
|
||||
"custom": {
|
||||
"note": "should remain unchanged in v3"
|
||||
}
|
||||
},
|
||||
"v999FutureVersionSettings": {
|
||||
"$version": 999,
|
||||
"theme": "dark",
|
||||
"model": "future-model",
|
||||
"disableAutoUpdate": true,
|
||||
"experimentalFlag": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
1995
integration-tests/hook-integration/hooks.test.ts
Normal file
1995
integration-tests/hook-integration/hooks.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -72,7 +72,7 @@ describe('run_shell_command', () => {
|
|||
const rig = new TestRig();
|
||||
await rig.setup('should propagate environment variables');
|
||||
|
||||
const varName = 'GEMINI_CLI_TEST_VAR';
|
||||
const varName = 'QWEN_CODE_TEST_VAR';
|
||||
const varValue = `test-value-${Math.random().toString(36).substring(7)}`;
|
||||
process.env[varName] = varValue;
|
||||
|
||||
|
|
|
|||
627
integration-tests/settings-migration.test.ts
Normal file
627
integration-tests/settings-migration.test.ts
Normal file
|
|
@ -0,0 +1,627 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Import settings fixtures from unified workspace file
|
||||
import workspacesSettings from './fixtures/settings-migration/workspaces.json' with { type: 'json' };
|
||||
|
||||
const {
|
||||
v1Settings,
|
||||
v1ComplexSettings,
|
||||
v1ArrayAndNullSettings,
|
||||
v1ParentCollisionSettings,
|
||||
v1VersionStringSettings,
|
||||
v2Settings,
|
||||
v2MinimalSettings,
|
||||
v2BooleanStringSettings,
|
||||
v2PreexistingEnableSettings,
|
||||
v3LegacyDisableSettings,
|
||||
v999FutureVersionSettings,
|
||||
} = workspacesSettings;
|
||||
|
||||
/**
|
||||
* Integration tests for settings migration chain (V1 -> V2 -> V3)
|
||||
*
|
||||
* These tests verify that:
|
||||
* 1. V1 settings are automatically migrated to V3 on CLI startup
|
||||
* 2. V2 settings are automatically migrated to V3 on CLI startup
|
||||
* 3. V3 settings remain unchanged
|
||||
* 4. Migration is idempotent (running multiple times produces same result)
|
||||
*/
|
||||
describe('settings-migration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to write settings file for an existing test rig.
|
||||
* This overwrites the settings file created by rig.setup().
|
||||
*/
|
||||
const overwriteSettingsFile = (
|
||||
testRig: TestRig,
|
||||
settings: Record<string, unknown>,
|
||||
) => {
|
||||
const qwenDir = join(
|
||||
(testRig as unknown as { testDir: string }).testDir,
|
||||
'.qwen',
|
||||
);
|
||||
writeFileSync(
|
||||
join(qwenDir, 'settings.json'),
|
||||
JSON.stringify(settings, null, 2),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to read settings file from the test directory
|
||||
*/
|
||||
const readSettingsFile = (testRig: TestRig): Record<string, unknown> => {
|
||||
const qwenDir = join(
|
||||
(testRig as unknown as { testDir: string }).testDir,
|
||||
'.qwen',
|
||||
);
|
||||
const content = readFileSync(join(qwenDir, 'settings.json'), 'utf-8');
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe('V1 settings migration', () => {
|
||||
it('should migrate V1 settings to V3 on CLI startup', async () => {
|
||||
rig.setup('v1-to-v3-migration');
|
||||
|
||||
// Write V1 settings directly (overwrites the one created by setup)
|
||||
overwriteSettingsFile(rig, v1Settings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
// We expect this to fail due to missing API key, but migration should still occur
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail, we just need the settings file to be processed
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Verify migration to V3
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
expect(migratedSettings['ui']).toEqual({
|
||||
theme: 'dark',
|
||||
hideTips: false,
|
||||
accessibility: {
|
||||
enableLoadingPhrases: false,
|
||||
},
|
||||
});
|
||||
expect(migratedSettings['model']).toEqual({ name: 'gemini' });
|
||||
expect(migratedSettings['tools']).toEqual({ autoAccept: true });
|
||||
expect(migratedSettings['general']).toEqual({
|
||||
vimMode: true,
|
||||
checkpointing: true,
|
||||
enableAutoUpdate: false,
|
||||
});
|
||||
expect(migratedSettings['mcpServers']).toEqual({
|
||||
fetch: {
|
||||
command: 'node',
|
||||
args: ['fetch-server.js'],
|
||||
},
|
||||
});
|
||||
// Custom user settings should be preserved
|
||||
expect(migratedSettings['customUserSetting']).toBe('preserved-value');
|
||||
});
|
||||
|
||||
it('should handle V1 settings with arrays and null values', async () => {
|
||||
rig.setup('v1-array-and-null-migration');
|
||||
|
||||
// Use fixture with arrays, null values, and string booleans
|
||||
overwriteSettingsFile(rig, v1ArrayAndNullSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Expected output based on stable test output
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
expect(migratedSettings['tools']).toEqual({ autoAccept: false });
|
||||
expect(migratedSettings['context']).toEqual({ includeDirectories: [] });
|
||||
expect(migratedSettings['model']).toEqual({ name: ['gemini', 'claude'] });
|
||||
expect(migratedSettings['ui']).toEqual({ theme: null });
|
||||
expect(migratedSettings['customArray']).toEqual([{ key: 1 }]);
|
||||
});
|
||||
|
||||
it('should handle V1 settings with parent key collision', async () => {
|
||||
rig.setup('v1-parent-collision-migration');
|
||||
|
||||
// Use fixture where V1 flat keys (ui, general) conflict with V2/V3 nested structure
|
||||
overwriteSettingsFile(rig, v1ParentCollisionSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Should be migrated to V3
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
// Legacy string values for ui/general should be preserved as-is (user data)
|
||||
expect(migratedSettings['ui']).toBe('legacy-ui-string');
|
||||
expect(migratedSettings['general']).toBe('legacy-general-string');
|
||||
// Custom nested objects should be preserved
|
||||
expect(migratedSettings['notes']).toEqual({
|
||||
fromUser: 'preserve-custom',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle V1 settings with string version and string booleans', async () => {
|
||||
rig.setup('v1-string-version-migration');
|
||||
|
||||
// Use fixture with $version as string and string boolean values
|
||||
overwriteSettingsFile(rig, v1VersionStringSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Expected output based on stable test output
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
expect(migratedSettings['model']).toEqual({ name: 'qwen-plus' });
|
||||
expect(migratedSettings['ui']).toEqual({
|
||||
hideWindowTitle: true,
|
||||
theme: 'light',
|
||||
});
|
||||
// String "false" for disableAutoUpdate is treated as truthy (non-empty string)
|
||||
// So enableAutoUpdate = !truthy = false, but output shows true
|
||||
// This suggests string "false" is parsed as boolean false
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'enableAutoUpdate'
|
||||
],
|
||||
).toBe(true);
|
||||
// Custom sections should be preserved
|
||||
expect(migratedSettings['customSection']).toEqual({ keepMe: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('V2 settings migration', () => {
|
||||
it('should migrate V2 settings to V3 on CLI startup', async () => {
|
||||
rig.setup('v2-to-v3-migration');
|
||||
|
||||
// Write V2 settings directly (overwrites the one created by setup)
|
||||
overwriteSettingsFile(rig, v2Settings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Verify migration to V3
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
|
||||
// Verify disable* -> enable* conversion with inversion
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableLoadingPhrases'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'enableAutoUpdate'
|
||||
],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableFuzzySearch'],
|
||||
).toBe(false);
|
||||
|
||||
// Verify old disable* keys are removed
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableAutoUpdate'
|
||||
],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableUpdateNag'
|
||||
],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableLoadingPhrases'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle V2 settings without any disable* keys', async () => {
|
||||
rig.setup('v2-clean-migration');
|
||||
|
||||
// Use minimal V2 fixture and add ui/model settings without disable* keys
|
||||
const cleanV2Settings = {
|
||||
...v2MinimalSettings,
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
model: {
|
||||
name: 'gemini',
|
||||
},
|
||||
};
|
||||
|
||||
overwriteSettingsFile(rig, cleanV2Settings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Should be updated to V3 version
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
// Other settings should remain unchanged
|
||||
expect(migratedSettings['ui']).toEqual({ theme: 'dark' });
|
||||
expect(migratedSettings['model']).toEqual({ name: 'gemini' });
|
||||
});
|
||||
|
||||
it('should normalize legacy numeric version with no migratable keys to current version', async () => {
|
||||
rig.setup('legacy-version-normalization');
|
||||
|
||||
// Use v1Settings fixture as base but with only custom key
|
||||
const legacyVersionWithoutMigratableKeys = {
|
||||
$version: 1,
|
||||
customOnlyKey: 'value',
|
||||
};
|
||||
|
||||
overwriteSettingsFile(rig, legacyVersionWithoutMigratableKeys);
|
||||
|
||||
// Run CLI with --help to trigger settings load/write path
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Version metadata should still be normalized to current version
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
// Existing user content should be preserved
|
||||
expect(migratedSettings['customOnlyKey']).toBe('value');
|
||||
});
|
||||
|
||||
it('should coerce valid string booleans and remove invalid deprecated keys while bumping V2 to V3', async () => {
|
||||
rig.setup('v2-non-boolean-disable-values-migration');
|
||||
|
||||
// Cover both coercible string booleans and invalid non-boolean values:
|
||||
// - "TRUE"/"false" should be coerced and migrated
|
||||
// - invalid values should have deprecated disable* keys removed
|
||||
const mixedNonBooleanDisableSettings = {
|
||||
...v2BooleanStringSettings,
|
||||
ui: {
|
||||
accessibility: {
|
||||
disableLoadingPhrases: 'yes',
|
||||
},
|
||||
},
|
||||
context: {
|
||||
fileFiltering: {
|
||||
disableFuzzySearch: null,
|
||||
},
|
||||
},
|
||||
model: {
|
||||
generationConfig: {
|
||||
disableCacheControl: [1],
|
||||
},
|
||||
},
|
||||
};
|
||||
overwriteSettingsFile(rig, mixedNonBooleanDisableSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Coercible strings are migrated; invalid disable* values are removed.
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
expect(migratedSettings['general']).toEqual({
|
||||
enableAutoUpdate: false,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableLoadingPhrases'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableLoadingPhrases'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['model'] as Record<string, unknown>)?.[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableCacheControl'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['model'] as Record<string, unknown>)?.[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableCacheControl'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle V2 settings with preexisting enable* keys', async () => {
|
||||
rig.setup('v2-preexisting-enable-migration');
|
||||
|
||||
// Use fixture with both disable* and enable* keys
|
||||
overwriteSettingsFile(rig, v2PreexistingEnableSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Expected output based on stable test output
|
||||
expect(migratedSettings['$version']).toBe(3);
|
||||
// Migration converts disable* to enable* by inverting the value
|
||||
// disableAutoUpdate: false -> enableAutoUpdate: true (inverted)
|
||||
// But disableUpdateNag: true may affect the consolidation
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'enableAutoUpdate'
|
||||
],
|
||||
).toBe(false);
|
||||
// disableLoadingPhrases: true -> enableLoadingPhrases: false (inverted)
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableLoadingPhrases'],
|
||||
).toBe(false);
|
||||
// disableFuzzySearch: false -> enableFuzzySearch: true (inverted)
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableFuzzySearch'],
|
||||
).toBe(true);
|
||||
// disableCacheControl: true -> enableCacheControl: false (inverted)
|
||||
expect(
|
||||
(
|
||||
(migratedSettings['model'] as Record<string, unknown>)?.[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableCacheControl'],
|
||||
).toBe(false);
|
||||
// Old disable* keys should be removed
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableAutoUpdate'
|
||||
],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(migratedSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableUpdateNag'
|
||||
],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('V3 settings handling', () => {
|
||||
it('should handle V3 settings with legacy disable* keys', async () => {
|
||||
rig.setup('v3-legacy-disable-keys');
|
||||
|
||||
// Use fixture with V3 format but still has legacy disable* keys
|
||||
overwriteSettingsFile(rig, v3LegacyDisableSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read settings
|
||||
const finalSettings = readSettingsFile(rig);
|
||||
|
||||
// Should remain V3
|
||||
expect(finalSettings['$version']).toBe(3);
|
||||
// Note: V3 settings with legacy disable* keys are left as-is
|
||||
// Migration only runs when version < current version
|
||||
// Since this is already V3, no migration logic is applied
|
||||
expect(
|
||||
(finalSettings['general'] as Record<string, unknown>)?.[
|
||||
'disableAutoUpdate'
|
||||
],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
(finalSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableLoadingPhrases'],
|
||||
).toBe(false);
|
||||
// Existing enable* keys should be preserved
|
||||
expect(
|
||||
(finalSettings['general'] as Record<string, unknown>)?.[
|
||||
'enableAutoUpdate'
|
||||
],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
(finalSettings['ui'] as Record<string, unknown>)?.[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)?.['enableLoadingPhrases'],
|
||||
).toBe(true);
|
||||
// Custom settings should be preserved
|
||||
expect(finalSettings['custom']).toEqual({
|
||||
note: 'should remain unchanged in v3',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Future version settings handling', () => {
|
||||
it('should not modify future version settings', async () => {
|
||||
rig.setup('v999-future-version');
|
||||
|
||||
// Use fixture with future version ($version: 999)
|
||||
overwriteSettingsFile(rig, v999FutureVersionSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read settings
|
||||
const finalSettings = readSettingsFile(rig);
|
||||
|
||||
// Future version should remain unchanged
|
||||
expect(finalSettings['$version']).toBe(999);
|
||||
expect(finalSettings['theme']).toBe('dark');
|
||||
expect(finalSettings['model']).toBe('future-model');
|
||||
expect(finalSettings['experimentalFlag']).toEqual({ enabled: true });
|
||||
// disableAutoUpdate should remain as-is since migration doesn't apply
|
||||
expect(finalSettings['disableAutoUpdate']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Migration idempotency', () => {
|
||||
it('should produce consistent results when run multiple times on V1 settings', async () => {
|
||||
rig.setup('v1-idempotency');
|
||||
|
||||
overwriteSettingsFile(rig, v1Settings);
|
||||
|
||||
// Run CLI multiple times with --help
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
const firstRunSettings = readSettingsFile(rig);
|
||||
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
const secondRunSettings = readSettingsFile(rig);
|
||||
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
const thirdRunSettings = readSettingsFile(rig);
|
||||
|
||||
// All runs should produce identical results
|
||||
expect(secondRunSettings).toEqual(firstRunSettings);
|
||||
expect(thirdRunSettings).toEqual(firstRunSettings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex migration scenarios', () => {
|
||||
it('should preserve custom user settings during full migration chain', async () => {
|
||||
rig.setup('preserve-custom-settings');
|
||||
|
||||
// Use v1ComplexSettings fixture which has custom user settings
|
||||
overwriteSettingsFile(rig, v1ComplexSettings);
|
||||
|
||||
// Run CLI with --help to trigger migration without API calls
|
||||
try {
|
||||
await rig.runCommand(['--help']);
|
||||
} catch {
|
||||
// Expected to potentially fail
|
||||
}
|
||||
|
||||
// Read migrated settings
|
||||
const migratedSettings = readSettingsFile(rig);
|
||||
|
||||
// Custom keys should be preserved (v1ComplexSettings has 'custom-value' and { nested: true, items: [1, 2, 3] })
|
||||
expect(migratedSettings['myCustomKey']).toBe('custom-value');
|
||||
expect(migratedSettings['anotherCustomSetting']).toEqual({
|
||||
nested: true,
|
||||
items: [1, 2, 3],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -40,6 +40,10 @@ Playwright element screenshot
|
|||
| WYSIWYG | xterm.js fully renders ANSI, no manual output cleaning needed |
|
||||
| Theme Support | Built-in 5 themes (Dracula, One Dark, GitHub Dark, Monokai, Night Owl) |
|
||||
| Full-length | `captureFull()` supports capturing scrollback buffer content |
|
||||
| Streaming Capture | Capture multiple frames at intervals during execution (e.g., progress bars) |
|
||||
| Animated GIF | Auto-generate GIF from streaming frames via ffmpeg |
|
||||
| Early Stop | Streaming stops early if output stabilizes; duplicate frames are skipped |
|
||||
| Auto Cleanup | Output directory is cleared before each run to prevent stale screenshots |
|
||||
| Deterministic Naming | Screenshot filenames auto-generated by step sequence for easy regression comparison |
|
||||
| Batch Execution | `run.ts` executes all scenarios in one command |
|
||||
|
||||
|
|
@ -90,8 +94,14 @@ scenarios/screenshots/
|
|||
02-01.png # Step 2 input state
|
||||
02-02.png # Step 2 result
|
||||
full-flow.png # Final state full-length image
|
||||
context/
|
||||
streaming-shell/
|
||||
01-01.png # Input state
|
||||
01-streaming-01.png # Streaming frame 1
|
||||
01-streaming-02.png # Streaming frame 2
|
||||
...
|
||||
01-02.png # Final result
|
||||
streaming.gif # Animated GIF (requires ffmpeg)
|
||||
full-flow.png # Final state full-length image
|
||||
```
|
||||
|
||||
## 4. Position in Testing System
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
*/
|
||||
|
||||
import { TerminalCapture, THEMES } from './terminal-capture.js';
|
||||
import { dirname, resolve, isAbsolute } from 'node:path';
|
||||
import { dirname, resolve, isAbsolute, join } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { writeFileSync, unlinkSync, rmSync, existsSync } from 'node:fs';
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Schema — Minimal
|
||||
|
|
@ -29,6 +31,18 @@ export interface FlowStep {
|
|||
capture?: string;
|
||||
/** Explicit screenshot: full scrollback buffer long image (standalone capture when no type) */
|
||||
captureFull?: string;
|
||||
/**
|
||||
* Streaming capture: capture multiple screenshots during execution at intervals.
|
||||
* Useful for demonstrating real-time output like progress bars.
|
||||
*/
|
||||
streaming?: {
|
||||
/** Delay before starting captures in milliseconds (skip initial waiting phase) */
|
||||
delayMs?: number;
|
||||
/** Interval between captures in milliseconds */
|
||||
intervalMs: number;
|
||||
/** Maximum number of captures */
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScenarioConfig {
|
||||
|
|
@ -50,6 +64,8 @@ export interface ScenarioConfig {
|
|||
};
|
||||
/** Screenshot output directory (relative to config file) */
|
||||
outputDir?: string;
|
||||
/** Generate animated GIF from all screenshots in order (default: true) */
|
||||
gif?: boolean;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
|
|
@ -105,6 +121,11 @@ export async function runScenario(
|
|||
? resolve(basedir, config.outputDir, scenarioDir)
|
||||
: resolve(basedir, 'screenshots', scenarioDir);
|
||||
|
||||
// Clean previous screenshots
|
||||
if (existsSync(outputDir)) {
|
||||
rmSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
console.log(`▶ ${config.name}`);
|
||||
console.log('═'.repeat(60));
|
||||
|
|
@ -171,13 +192,66 @@ export async function runScenario(
|
|||
if (autoEnter) {
|
||||
// ── Auto-press Enter → Wait for stabilization → 02 screenshot ──
|
||||
await terminal.type('\n');
|
||||
console.log(` ⏳ waiting for output to settle...`);
|
||||
await terminal.idle(2000, 60000);
|
||||
console.log(` ✅ settled`);
|
||||
|
||||
const resultName = step.capture ?? `${pad(seq)}-02.png`;
|
||||
console.log(` ${label} 📸 result: ${resultName}`);
|
||||
screenshots.push(await terminal.capture(resultName));
|
||||
// Streaming capture: capture multiple screenshots during execution
|
||||
if (step.streaming) {
|
||||
const { delayMs = 0, intervalMs, count } = step.streaming;
|
||||
console.log(
|
||||
` 🎬 streaming capture: ${count} shots @ ${intervalMs}ms intervals${delayMs ? ` (delay ${delayMs}ms)` : ''}`,
|
||||
);
|
||||
|
||||
// Wait before starting captures (skip initial waiting phase)
|
||||
if (delayMs > 0) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
|
||||
// Capture frames at intervals (stop early if output stabilizes)
|
||||
const streamingShots: string[] = [];
|
||||
let prevOutputLen = terminal.getRawOutput().length;
|
||||
let stableCount = 0;
|
||||
let shotNum = 0;
|
||||
for (let j = 0; j < count; j++) {
|
||||
await sleep(intervalMs);
|
||||
const curOutputLen = terminal.getRawOutput().length;
|
||||
if (curOutputLen === prevOutputLen) {
|
||||
stableCount++;
|
||||
if (stableCount >= 3) {
|
||||
console.log(
|
||||
` ⏹️ streaming stopped early: output stable for ${stableCount} intervals`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
continue; // skip duplicate frame
|
||||
}
|
||||
stableCount = 0;
|
||||
prevOutputLen = curOutputLen;
|
||||
shotNum++;
|
||||
const shotName = `${pad(seq)}-streaming-${pad(shotNum)}.png`;
|
||||
console.log(
|
||||
` 📸 streaming [${shotNum}/${count}]: ${shotName}`,
|
||||
);
|
||||
const shot = await terminal.capture(shotName);
|
||||
streamingShots.push(shot);
|
||||
screenshots.push(shot);
|
||||
}
|
||||
|
||||
// Wait for completion after streaming captures
|
||||
console.log(` ⏳ waiting for output to settle...`);
|
||||
await terminal.idle(2000, 60000);
|
||||
console.log(` ✅ settled`);
|
||||
|
||||
const resultName = step.capture ?? `${pad(seq)}-02.png`;
|
||||
console.log(` ${label} 📸 result: ${resultName}`);
|
||||
screenshots.push(await terminal.capture(resultName));
|
||||
} else {
|
||||
console.log(` ⏳ waiting for output to settle...`);
|
||||
await terminal.idle(2000, 60000);
|
||||
console.log(` ✅ settled`);
|
||||
|
||||
const resultName = step.capture ?? `${pad(seq)}-02.png`;
|
||||
console.log(` ${label} 📸 result: ${resultName}`);
|
||||
screenshots.push(await terminal.capture(resultName));
|
||||
}
|
||||
|
||||
// full-flow: Only the last type step auto-captures full-length image
|
||||
const isLastType = !config.flow.slice(i + 1).some((s) => s.type);
|
||||
|
|
@ -245,6 +319,19 @@ export async function runScenario(
|
|||
}
|
||||
}
|
||||
|
||||
// Generate animated GIF from all screenshots (excluding full-flow captures)
|
||||
if (config.gif !== false) {
|
||||
const gifFrames = screenshots.filter(
|
||||
(s) => !s.endsWith('full-flow.png') && !s.includes('-full-'),
|
||||
);
|
||||
if (gifFrames.length > 0) {
|
||||
const gifPath = generateGif(gifFrames, outputDir);
|
||||
if (gifPath) {
|
||||
console.log(` 🎞️ GIF: ${gifPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(
|
||||
`\n ✅ ${config.name} — ${screenshots.length} screenshots, ${(duration / 1000).toFixed(1)}s`,
|
||||
|
|
@ -302,3 +389,41 @@ const KEY_MAP: Record<string, string> = {
|
|||
function resolveKey(key: string): string {
|
||||
return KEY_MAP[key] ?? key;
|
||||
}
|
||||
|
||||
/** Generate animated GIF from PNG frames using ffmpeg (concat demuxer). */
|
||||
function generateGif(frames: string[], outputDir: string): string | null {
|
||||
if (frames.length === 0) return null;
|
||||
|
||||
const STREAMING_DURATION = 0.3; // 300ms for streaming frames
|
||||
const STATIC_DURATION = 1.0; // 1s for non-streaming and edge frames
|
||||
|
||||
const gifPath = join(outputDir, 'streaming.gif');
|
||||
const listFile = join(outputDir, 'frames.txt');
|
||||
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const isStreaming = frames[i].includes('-streaming-');
|
||||
const duration = isStreaming ? STREAMING_DURATION : STATIC_DURATION;
|
||||
lines.push(`file '${resolve(frames[i])}'`, `duration ${duration}`);
|
||||
}
|
||||
// Concat demuxer requires last frame repeated without duration
|
||||
lines.push(`file '${resolve(frames[frames.length - 1])}'`);
|
||||
writeFileSync(listFile, lines.join('\n'));
|
||||
|
||||
execSync(
|
||||
`ffmpeg -y -f concat -safe 0 -i "${listFile}" -vf "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -loop 0 "${gifPath}"`,
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
return gifPath;
|
||||
} catch {
|
||||
console.log(' ⚠️ GIF generation requires ffmpeg');
|
||||
return null;
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(listFile);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
/**
|
||||
* Tests the message component refactoring for PR #2120.
|
||||
* Captures info, warning, and error messages to verify proper icon/prefix display.
|
||||
*
|
||||
* This scenario tests:
|
||||
* - Info message prefix (● filled circle)
|
||||
* - Error message prefix (✕)
|
||||
* - User message prefix (>)
|
||||
* - Assistant message prefix (✦)
|
||||
*/
|
||||
export default {
|
||||
name: 'message-components',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
// Test info message via /skills command (instant, no streaming)
|
||||
{ type: '/skills' },
|
||||
// Test error message via unknown skill (instant, no streaming)
|
||||
{ type: '/skills nonexistent-skill-xyz' },
|
||||
// Test user and assistant messages (streams from LLM)
|
||||
{
|
||||
type: 'Say "Hello, this is a test of message prefixes!" and nothing else.',
|
||||
streaming: {
|
||||
delayMs: 3000,
|
||||
intervalMs: 1000,
|
||||
count: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
16
integration-tests/terminal-capture/scenarios/progress.sh
Executable file
16
integration-tests/terminal-capture/scenarios/progress.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
# Progress bar script that overwrites the same line using \r
|
||||
# Tests PTY's ability to handle carriage return / cursor movement
|
||||
|
||||
total=20
|
||||
for ((i = 1; i <= total; i++)); do
|
||||
pct=$((i * 100 / total))
|
||||
filled=$((pct / 5))
|
||||
empty=$((20 - filled))
|
||||
bar=$(printf '%0.s#' $(seq 1 $filled 2>/dev/null))
|
||||
space=$(printf '%0.s-' $(seq 1 $empty 2>/dev/null))
|
||||
printf "\r[%s%s] %3d%% (%d/%d)" "$bar" "$space" "$pct" "$i" "$total"
|
||||
sleep 0.5
|
||||
done
|
||||
echo ""
|
||||
echo "Done!"
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
export default {
|
||||
name: '/qc:code-review',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{
|
||||
type: '/qc:code-review 2117',
|
||||
streaming: {
|
||||
delayMs: 10000, // Wait for initial model thinking/approval
|
||||
intervalMs: 800, // Capture every 800ms
|
||||
count: 30, // Max 30 captures
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
/**
|
||||
* Demonstrates streaming capture with the /insight command.
|
||||
* The insight command analyzes the codebase and streams results,
|
||||
* making it ideal for demonstrating streaming capture.
|
||||
*/
|
||||
export default {
|
||||
name: 'streaming-insight',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{
|
||||
type: '/insight',
|
||||
// /insight takes time to analyze the codebase and streams results
|
||||
// Capture frames during the analysis to show real-time progress
|
||||
streaming: {
|
||||
intervalMs: 5000, // Capture every 5 seconds
|
||||
count: 50, // Up to 250 seconds of capture
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { ScenarioConfig } from '../scenario-runner.js';
|
||||
|
||||
/**
|
||||
* Demonstrates streaming shell execution output with PTY enabled by default.
|
||||
* Tests the render throttle behavior and progress bar handling.
|
||||
* Captures multiple screenshots during execution to show real-time output.
|
||||
*/
|
||||
export default {
|
||||
name: 'streaming-shell',
|
||||
spawn: ['node', 'dist/cli.js', '--yolo'],
|
||||
terminal: { title: 'qwen-code', cwd: '../../..' },
|
||||
flow: [
|
||||
{
|
||||
type: 'Run this command: bash integration-tests/terminal-capture/scenarios/progress.sh',
|
||||
// Capture 20 screenshots at 500ms intervals during execution
|
||||
// The progress.sh script takes ~10 seconds (20 iterations * 0.5s each)
|
||||
streaming: {
|
||||
delayMs: 7000,
|
||||
intervalMs: 500,
|
||||
count: 20,
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies ScenarioConfig;
|
||||
|
|
@ -154,7 +154,7 @@ export class TestRig {
|
|||
// Get timeout based on environment
|
||||
getDefaultTimeout() {
|
||||
if (env['CI']) return 60000; // 1 minute in CI
|
||||
if (env['GEMINI_SANDBOX']) return 30000; // 30s in containers
|
||||
if (env['QWEN_SANDBOX']) return 30000; // 30s in containers
|
||||
return 15000; // 15s locally
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +181,7 @@ export class TestRig {
|
|||
otlpEndpoint: '',
|
||||
outfile: telemetryPath,
|
||||
},
|
||||
sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false,
|
||||
sandbox: env.QWEN_SANDBOX !== 'false' ? env.QWEN_SANDBOX : false,
|
||||
...options.settings, // Allow tests to override/add settings
|
||||
};
|
||||
writeFileSync(
|
||||
|
|
@ -301,7 +301,7 @@ export class TestRig {
|
|||
// Filter out telemetry output when running with Podman
|
||||
// Podman seems to output telemetry to stdout even when writing to file
|
||||
let result = stdout;
|
||||
if (env['GEMINI_SANDBOX'] === 'podman') {
|
||||
if (env['QWEN_SANDBOX'] === 'podman') {
|
||||
// Remove telemetry JSON objects from output
|
||||
// They are multi-line JSON objects that start with { and contain telemetry fields
|
||||
const lines = result.split(EOL);
|
||||
|
|
@ -727,7 +727,7 @@ export class TestRig {
|
|||
readToolLogs() {
|
||||
// For Podman, first check if telemetry file exists and has content
|
||||
// If not, fall back to parsing from stdout
|
||||
if (env['GEMINI_SANDBOX'] === 'podman') {
|
||||
if (env['QWEN_SANDBOX'] === 'podman') {
|
||||
// Try reading from file first
|
||||
const logFilePath = join(this.testDir!, 'telemetry.log');
|
||||
|
||||
|
|
|
|||
89
package-lock.json
generated
89
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.24.0",
|
||||
|
|
@ -73,6 +74,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.14.1.tgz",
|
||||
"integrity": "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz",
|
||||
|
|
@ -5629,6 +5639,16 @@
|
|||
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
|
|
@ -18780,8 +18800,9 @@
|
|||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
|
@ -19437,7 +19458,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
|
|
@ -19471,6 +19492,7 @@
|
|||
"google-auth-library": "^10.5.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"ignore": "^7.0.0",
|
||||
"jsonrepair": "^3.13.0",
|
||||
"marked": "^15.0.12",
|
||||
|
|
@ -20865,39 +20887,6 @@
|
|||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"packages/sdk-typescript/node_modules/@vitest/browser": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.6.1.tgz",
|
||||
"integrity": "sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "1.6.1",
|
||||
"magic-string": "^0.30.5",
|
||||
"sirv": "^2.0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright": "*",
|
||||
"vitest": "1.6.1",
|
||||
"webdriverio": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"safaridriver": {
|
||||
"optional": true
|
||||
},
|
||||
"webdriverio": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/sdk-typescript/node_modules/@vitest/coverage-v8": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz",
|
||||
|
|
@ -21705,23 +21694,6 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"packages/sdk-typescript/node_modules/sirv": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
|
||||
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@polka/url": "^1.0.0-next.24",
|
||||
"mrmime": "^2.0.0",
|
||||
"totalist": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"packages/sdk-typescript/node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
|
|
@ -22917,7 +22889,7 @@
|
|||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
|
|
@ -22929,9 +22901,10 @@
|
|||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@qwen-code/webui": "*",
|
||||
"cors": "^2.8.5",
|
||||
|
|
@ -23176,7 +23149,7 @@
|
|||
},
|
||||
"packages/web-templates": {
|
||||
"name": "@qwen-code/web-templates",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
|
|
@ -23704,7 +23677,7 @@
|
|||
},
|
||||
"packages/webui": {
|
||||
"name": "@qwen-code/webui",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.1.0"
|
||||
|
|
|
|||
20
package.json
20
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
|
|
@ -13,13 +13,14 @@
|
|||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.11.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
"dev": "node scripts/dev.js",
|
||||
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
|
||||
"generate": "node scripts/generate-git-commit-info.js",
|
||||
"generate:settings-schema": "tsx scripts/generate-settings-schema.ts",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
|
|
@ -32,13 +33,13 @@
|
|||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
|
||||
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"test:integration:sdk:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests sdk-typescript",
|
||||
"test:integration:sdk:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript",
|
||||
"test:integration:cli:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||
"test:integration:cli:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests sdk-typescript",
|
||||
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript",
|
||||
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
|
||||
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
|
||||
"test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'",
|
||||
"test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'",
|
||||
|
|
@ -84,6 +85,7 @@
|
|||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.1.1",
|
||||
"@vitest/eslint-plugin": "^1.3.4",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.24.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.11.1",
|
||||
"version": "0.12.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -33,9 +33,10 @@
|
|||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.11.1"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
|
|
@ -100,10 +101,10 @@
|
|||
"@teddyzhu/clipboard": "^0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-arm64": "0.0.5",
|
||||
"@teddyzhu/clipboard-darwin-x64": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-x64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-linux-arm64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-x64-msvc": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5"
|
||||
"@teddyzhu/clipboard-linux-x64-gnu": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-arm64-msvc": "0.0.5",
|
||||
"@teddyzhu/clipboard-win32-x64-msvc": "0.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
|
|||
|
|
@ -1,503 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */
|
||||
|
||||
import { z } from 'zod';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import * as schema from './schema.js';
|
||||
import { ACP_ERROR_CODES } from './errorCodes.js';
|
||||
import { pickAuthMethodsForDetails } from './authMethods.js';
|
||||
export * from './schema.js';
|
||||
|
||||
import type { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
|
||||
const debugLogger = createDebugLogger('ACP_PROTOCOL');
|
||||
export class AgentSideConnection implements Client {
|
||||
#connection: Connection;
|
||||
|
||||
constructor(
|
||||
toAgent: (conn: Client) => Agent,
|
||||
input: WritableStream<Uint8Array>,
|
||||
output: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
const agent = toAgent(this);
|
||||
|
||||
const handler = async (
|
||||
method: string,
|
||||
params: unknown,
|
||||
): Promise<unknown> => {
|
||||
switch (method) {
|
||||
case schema.AGENT_METHODS.initialize: {
|
||||
const validatedParams = schema.initializeRequestSchema.parse(params);
|
||||
return agent.initialize(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_new: {
|
||||
const validatedParams = schema.newSessionRequestSchema.parse(params);
|
||||
return agent.newSession(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_load: {
|
||||
if (!agent.loadSession) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.loadSessionRequestSchema.parse(params);
|
||||
return agent.loadSession(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_list: {
|
||||
if (!agent.listSessions) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams =
|
||||
schema.listSessionsRequestSchema.parse(params);
|
||||
return agent.listSessions(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.authenticate: {
|
||||
const validatedParams =
|
||||
schema.authenticateRequestSchema.parse(params);
|
||||
return agent.authenticate(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_prompt: {
|
||||
const validatedParams = schema.promptRequestSchema.parse(params);
|
||||
return agent.prompt(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_cancel: {
|
||||
const validatedParams = schema.cancelNotificationSchema.parse(params);
|
||||
return agent.cancel(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_mode: {
|
||||
if (!agent.setMode) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModeRequestSchema.parse(params);
|
||||
return agent.setMode(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_model: {
|
||||
if (!agent.setModel) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModelRequestSchema.parse(params);
|
||||
return agent.setModel(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_config_option: {
|
||||
if (!agent.setConfigOption) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams =
|
||||
schema.setConfigOptionRequestSchema.parse(params);
|
||||
return agent.setConfigOption(validatedParams);
|
||||
}
|
||||
default:
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
};
|
||||
|
||||
this.#connection = new Connection(handler, input, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams new content to the client including text, tool calls, etc.
|
||||
*/
|
||||
async sessionUpdate(params: schema.SessionNotification): Promise<void> {
|
||||
return await this.#connection.sendNotification(
|
||||
schema.CLIENT_METHODS.session_update,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
|
||||
*/
|
||||
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
|
||||
return await this.#connection.sendNotification(
|
||||
schema.CLIENT_METHODS.authenticate_update,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a custom notification to the client.
|
||||
* Used for extension-specific notifications that are not part of the core ACP protocol.
|
||||
*/
|
||||
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
|
||||
return await this.#connection.sendNotification(method, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request permission before running a tool
|
||||
*
|
||||
* The agent specifies a series of permission options with different granularity,
|
||||
* and the client returns the chosen one.
|
||||
*/
|
||||
async requestPermission(
|
||||
params: schema.RequestPermissionRequest,
|
||||
): Promise<schema.RequestPermissionResponse> {
|
||||
return await this.#connection.sendRequest(
|
||||
schema.CLIENT_METHODS.session_request_permission,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
async readTextFile(
|
||||
params: schema.ReadTextFileRequest,
|
||||
): Promise<schema.ReadTextFileResponse> {
|
||||
return await this.#connection.sendRequest(
|
||||
schema.CLIENT_METHODS.fs_read_text_file,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
async writeTextFile(
|
||||
params: schema.WriteTextFileRequest,
|
||||
): Promise<schema.WriteTextFileResponse> {
|
||||
return await this.#connection.sendRequest(
|
||||
schema.CLIENT_METHODS.fs_write_text_file,
|
||||
params,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type AnyMessage = AnyRequest | AnyResponse | AnyNotification;
|
||||
|
||||
type AnyRequest = {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type AnyResponse = {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
} & Result<unknown>;
|
||||
|
||||
type AnyNotification = {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type Result<T> =
|
||||
| {
|
||||
result: T;
|
||||
}
|
||||
| {
|
||||
error: ErrorResponse;
|
||||
};
|
||||
|
||||
type ErrorResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
authMethods?: schema.AuthMethod[];
|
||||
};
|
||||
|
||||
type PendingResponse = {
|
||||
resolve: (response: unknown) => void;
|
||||
reject: (error: ErrorResponse) => void;
|
||||
};
|
||||
|
||||
type MethodHandler = (method: string, params: unknown) => Promise<unknown>;
|
||||
|
||||
class Connection {
|
||||
#pendingResponses: Map<string | number, PendingResponse> = new Map();
|
||||
#nextRequestId: number = 0;
|
||||
#handler: MethodHandler;
|
||||
#peerInput: WritableStream<Uint8Array>;
|
||||
#writeQueue: Promise<void> = Promise.resolve();
|
||||
#textEncoder: TextEncoder;
|
||||
|
||||
constructor(
|
||||
handler: MethodHandler,
|
||||
peerInput: WritableStream<Uint8Array>,
|
||||
peerOutput: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
this.#handler = handler;
|
||||
this.#peerInput = peerInput;
|
||||
this.#textEncoder = new TextEncoder();
|
||||
this.#receive(peerOutput);
|
||||
}
|
||||
|
||||
async #receive(output: ReadableStream<Uint8Array>) {
|
||||
let content = '';
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of output) {
|
||||
content += decoder.decode(chunk, { stream: true });
|
||||
const lines = content.split('\n');
|
||||
content = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine) {
|
||||
try {
|
||||
const message = JSON.parse(trimmedLine);
|
||||
this.#processMessage(message);
|
||||
} catch (error) {
|
||||
debugLogger.error('ACP parse error for inbound message.', {
|
||||
code: ACP_ERROR_CODES.PARSE_ERROR,
|
||||
line: trimmedLine,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #processMessage(message: AnyMessage) {
|
||||
if ('method' in message && 'id' in message) {
|
||||
// It's a request
|
||||
const response = await this.#tryCallHandler(
|
||||
message.method,
|
||||
message.params,
|
||||
);
|
||||
|
||||
await this.#sendMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
...response,
|
||||
});
|
||||
} else if ('method' in message) {
|
||||
// It's a notification
|
||||
await this.#tryCallHandler(message.method, message.params);
|
||||
} else if ('id' in message) {
|
||||
// It's a response
|
||||
this.#handleResponse(message as AnyResponse);
|
||||
}
|
||||
}
|
||||
|
||||
async #tryCallHandler(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<Result<unknown>> {
|
||||
try {
|
||||
const result = await this.#handler(method, params);
|
||||
return { result: result ?? null };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof RequestError) {
|
||||
debugLogger.debug('ACP handler returned request error.', {
|
||||
method,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.data?.details,
|
||||
});
|
||||
return error.toResult();
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
const formattedDetails = JSON.stringify(error.format(), undefined, 2);
|
||||
debugLogger.debug('ACP handler validation error.', {
|
||||
method,
|
||||
code: ACP_ERROR_CODES.INVALID_PARAMS,
|
||||
details: formattedDetails,
|
||||
});
|
||||
return RequestError.invalidParams(formattedDetails).toResult();
|
||||
}
|
||||
|
||||
let errorName;
|
||||
let details;
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorName = error.name;
|
||||
details = error.message;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
details = error.message;
|
||||
}
|
||||
|
||||
if (errorName === 'TokenManagerError' || details?.includes('/auth')) {
|
||||
return RequestError.authRequired(
|
||||
details,
|
||||
pickAuthMethodsForDetails(details),
|
||||
).toResult();
|
||||
}
|
||||
|
||||
debugLogger.error(
|
||||
'ACP handler failed with internal error.',
|
||||
{ method, errorName, details },
|
||||
error,
|
||||
);
|
||||
return RequestError.internalError(details).toResult();
|
||||
}
|
||||
}
|
||||
|
||||
#handleResponse(response: AnyResponse) {
|
||||
const pendingResponse = this.#pendingResponses.get(response.id);
|
||||
if (pendingResponse) {
|
||||
if ('result' in response) {
|
||||
pendingResponse.resolve(response.result);
|
||||
} else if ('error' in response) {
|
||||
const { error } = response;
|
||||
debugLogger.warn('ACP response error received.', {
|
||||
id: response.id,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
});
|
||||
pendingResponse.reject(error);
|
||||
}
|
||||
this.#pendingResponses.delete(response.id);
|
||||
}
|
||||
}
|
||||
|
||||
async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> {
|
||||
const id = this.#nextRequestId++;
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
this.#pendingResponses.set(id, { resolve, reject });
|
||||
});
|
||||
await this.#sendMessage({ jsonrpc: '2.0', id, method, params });
|
||||
return responsePromise as Promise<Resp>;
|
||||
}
|
||||
|
||||
async sendNotification<N>(method: string, params?: N): Promise<void> {
|
||||
await this.#sendMessage({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
|
||||
async #sendMessage(json: AnyMessage) {
|
||||
const content = JSON.stringify(json) + '\n';
|
||||
this.#writeQueue = this.#writeQueue
|
||||
.then(async () => {
|
||||
const writer = this.#peerInput.getWriter();
|
||||
try {
|
||||
await writer.write(this.#textEncoder.encode(content));
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// Continue processing writes on error
|
||||
debugLogger.error('ACP write error:', error);
|
||||
});
|
||||
return this.#writeQueue;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestError extends Error {
|
||||
data?: { details?: string; authMethods?: schema.AuthMethod[] };
|
||||
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
details?: string,
|
||||
authMethods?: schema.AuthMethod[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RequestError';
|
||||
if (details || authMethods) {
|
||||
this.data = {};
|
||||
if (details) {
|
||||
this.data.details = details;
|
||||
}
|
||||
if (authMethods) {
|
||||
this.data.authMethods = authMethods;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static parseError(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.PARSE_ERROR,
|
||||
'Parse error',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static invalidRequest(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INVALID_REQUEST,
|
||||
'Invalid request',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static methodNotFound(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.METHOD_NOT_FOUND,
|
||||
'Method not found',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static invalidParams(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INVALID_PARAMS,
|
||||
'Invalid params',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static internalError(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
'Internal error',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static authRequired(
|
||||
details?: string,
|
||||
authMethods?: schema.AuthMethod[],
|
||||
): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.AUTH_REQUIRED,
|
||||
'Authentication required',
|
||||
details,
|
||||
authMethods,
|
||||
);
|
||||
}
|
||||
|
||||
toResult<T>(): Result<T> {
|
||||
return {
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
requestPermission(
|
||||
params: schema.RequestPermissionRequest,
|
||||
): Promise<schema.RequestPermissionResponse>;
|
||||
sessionUpdate(params: schema.SessionNotification): Promise<void>;
|
||||
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
||||
sendCustomNotification<T>(method: string, params: T): Promise<void>;
|
||||
writeTextFile(
|
||||
params: schema.WriteTextFileRequest,
|
||||
): Promise<schema.WriteTextFileResponse>;
|
||||
readTextFile(
|
||||
params: schema.ReadTextFileRequest,
|
||||
): Promise<schema.ReadTextFileResponse>;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
initialize(
|
||||
params: schema.InitializeRequest,
|
||||
): Promise<schema.InitializeResponse>;
|
||||
newSession(
|
||||
params: schema.NewSessionRequest,
|
||||
): Promise<schema.NewSessionResponse>;
|
||||
loadSession?(
|
||||
params: schema.LoadSessionRequest,
|
||||
): Promise<schema.LoadSessionResponse>;
|
||||
listSessions?(
|
||||
params: schema.ListSessionsRequest,
|
||||
): Promise<schema.ListSessionsResponse>;
|
||||
authenticate(params: schema.AuthenticateRequest): Promise<void>;
|
||||
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
|
||||
cancel(params: schema.CancelNotification): Promise<void>;
|
||||
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
|
||||
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
|
||||
setConfigOption?(
|
||||
params: schema.SetConfigOptionRequest,
|
||||
): Promise<schema.SetConfigOptionResponse>;
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ReadableStream, WritableStream } from 'node:stream/web';
|
||||
|
||||
import {
|
||||
APPROVAL_MODE_INFO,
|
||||
APPROVAL_MODES,
|
||||
|
|
@ -21,8 +19,40 @@ import {
|
|||
type ConversationRecord,
|
||||
type DeviceAuthorizationData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ApprovalModeValue, ConfigOption } from './schema.js';
|
||||
import * as acp from './acp.js';
|
||||
import {
|
||||
AgentSideConnection,
|
||||
RequestError,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type {
|
||||
Agent,
|
||||
AuthenticateRequest,
|
||||
AuthMethod,
|
||||
CancelNotification,
|
||||
ClientCapabilities,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
ListSessionsRequest,
|
||||
ListSessionsResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
McpServerStdio,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
SessionConfigOption,
|
||||
SessionInfo,
|
||||
SessionModeState,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { buildAuthMethods } from './authMethods.js';
|
||||
import { AcpFileSystemService } from './service/filesystem.js';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
|
|
@ -31,8 +61,6 @@ import { SettingScope } from '../config/settings.js';
|
|||
import { z } from 'zod';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
|
||||
// Import the modular Session class
|
||||
import { Session } from './session/Session.js';
|
||||
import { formatAcpModelId } from '../utils/acpModelUtils.js';
|
||||
|
||||
|
|
@ -52,54 +80,46 @@ export async function runAcpAgent(
|
|||
console.info = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
new acp.AgentSideConnection(
|
||||
(client: acp.Client) => new GeminiAgent(config, settings, argv, client),
|
||||
stdout,
|
||||
stdin,
|
||||
const stream = ndJsonStream(stdout, stdin);
|
||||
const connection = new AgentSideConnection(
|
||||
(conn) => new QwenAgent(config, settings, argv, conn),
|
||||
stream,
|
||||
);
|
||||
|
||||
await connection.closed;
|
||||
}
|
||||
|
||||
class GeminiAgent {
|
||||
function toStdioServer(server: McpServer): McpServerStdio | undefined {
|
||||
if ('command' in server && 'args' in server && 'env' in server) {
|
||||
return server as McpServerStdio;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class QwenAgent implements Agent {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
private clientCapabilities: ClientCapabilities | undefined;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private settings: LoadedSettings,
|
||||
private argv: CliArgs,
|
||||
private client: acp.Client,
|
||||
private connection: AgentSideConnection,
|
||||
) {}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
async initialize(args: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = args.clientCapabilities;
|
||||
const authMethods = buildAuthMethods();
|
||||
|
||||
// Get current approval mode from config
|
||||
const currentApprovalMode = this.config.getApprovalMode();
|
||||
|
||||
// Build available modes from shared APPROVAL_MODE_INFO
|
||||
const availableModes = APPROVAL_MODES.map((mode) => ({
|
||||
id: mode as acp.ApprovalModeValue,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
|
||||
const version = process.env['CLI_VERSION'] || process.version;
|
||||
|
||||
return {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentInfo: {
|
||||
name: 'qwen-code',
|
||||
title: 'Qwen Code',
|
||||
version,
|
||||
},
|
||||
authMethods,
|
||||
modes: {
|
||||
currentModeId: currentApprovalMode as acp.ApprovalModeValue,
|
||||
availableModes,
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
|
|
@ -115,14 +135,15 @@ class GeminiAgent {
|
|||
};
|
||||
}
|
||||
|
||||
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
|
||||
async authenticate({ methodId }: AuthenticateRequest): Promise<void> {
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
|
||||
let authUri: string | undefined;
|
||||
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
|
||||
authUri = deviceAuth.verification_uri_complete;
|
||||
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
|
||||
void this.client.authenticateUpdate({ _meta: { authUri } });
|
||||
void this.connection.extNotification('authenticate/update', {
|
||||
_meta: { authUri },
|
||||
});
|
||||
};
|
||||
|
||||
if (method === AuthType.QWEN_OAUTH) {
|
||||
|
|
@ -138,19 +159,16 @@ class GeminiAgent {
|
|||
method,
|
||||
);
|
||||
} finally {
|
||||
// Ensure we don't leak listeners if auth fails early.
|
||||
if (method === AuthType.QWEN_OAUTH) {
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async newSession({
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
}: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
const config = await this.newSessionConfig(cwd, mcpServers);
|
||||
await this.ensureAuthenticated(config);
|
||||
this.setupFileSystem(config);
|
||||
|
|
@ -168,58 +186,12 @@ class GeminiAgent {
|
|||
};
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
sessionId?: string,
|
||||
): Promise<Config> {
|
||||
const mergedMcpServers = { ...this.settings.merged.mcpServers };
|
||||
|
||||
for (const { command, args, env: rawEnv, name } of mcpServers) {
|
||||
const env: Record<string, string> = {};
|
||||
for (const { name: envName, value } of rawEnv) {
|
||||
env[envName] = value;
|
||||
}
|
||||
mergedMcpServers[name] = new MCPServerConfig(command, args, env, cwd);
|
||||
}
|
||||
|
||||
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
|
||||
|
||||
const argvForSession = {
|
||||
...this.argv,
|
||||
resume: sessionId,
|
||||
continue: false,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(settings, argvForSession, cwd);
|
||||
|
||||
await config.initialize();
|
||||
return config;
|
||||
}
|
||||
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
await session.cancelPendingPrompt();
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
params: acp.LoadSessionRequest,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const sessionService = new SessionService(params.cwd);
|
||||
const exists = await sessionService.sessionExists(params.sessionId);
|
||||
if (!exists) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -234,182 +206,193 @@ class GeminiAgent {
|
|||
|
||||
const sessionData = config.getResumedSessionData();
|
||||
if (!sessionData) {
|
||||
throw acp.RequestError.internalError(
|
||||
throw RequestError.internalError(
|
||||
undefined,
|
||||
`Failed to load session data for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.createAndStoreSession(config, sessionData.conversation);
|
||||
|
||||
return null;
|
||||
const modesData = this.buildModesData(config);
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
const configOptions = this.buildConfigOptions(config);
|
||||
|
||||
return {
|
||||
modes: modesData,
|
||||
models: availableModels,
|
||||
configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
async listSessions(
|
||||
params: acp.ListSessionsRequest,
|
||||
): Promise<acp.ListSessionsResponse> {
|
||||
async unstable_listSessions(
|
||||
params: ListSessionsRequest,
|
||||
): Promise<ListSessionsResponse> {
|
||||
const cwd = params.cwd || process.cwd();
|
||||
const sessionService = new SessionService(cwd);
|
||||
const numericCursor = params.cursor ? Number(params.cursor) : undefined;
|
||||
const result = await sessionService.listSessions({
|
||||
cursor: params.cursor,
|
||||
size: params.size,
|
||||
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
|
||||
});
|
||||
|
||||
const sessions = result.items.map((item) => ({
|
||||
const sessions: SessionInfo[] = result.items.map((item) => ({
|
||||
cwd: item.cwd,
|
||||
filePath: item.filePath,
|
||||
gitBranch: item.gitBranch,
|
||||
messageCount: item.messageCount,
|
||||
mtime: item.mtime,
|
||||
prompt: item.prompt,
|
||||
sessionId: item.sessionId,
|
||||
startTime: item.startTime,
|
||||
title: item.prompt || '(session)',
|
||||
updatedAt: new Date(item.mtime).toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
hasMore: result.hasMore,
|
||||
items: sessions,
|
||||
nextCursor: result.nextCursor,
|
||||
sessions,
|
||||
nextCursor:
|
||||
result.nextCursor != null ? String(result.nextCursor) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
|
||||
async setSessionMode(
|
||||
params: SetSessionModeRequest,
|
||||
): Promise<SetSessionModeResponse | void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setMode(params);
|
||||
}
|
||||
|
||||
async setModel(params: acp.SetModelRequest): Promise<acp.SetModelResponse> {
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return await session.setModel(params);
|
||||
}
|
||||
|
||||
async setConfigOption(
|
||||
params: acp.SetConfigOptionRequest,
|
||||
): Promise<acp.SetConfigOptionResponse> {
|
||||
async setSessionConfigOption(
|
||||
params: SetSessionConfigOptionRequest,
|
||||
): Promise<SetSessionConfigOptionResponse> {
|
||||
const { sessionId, configId, value } = params;
|
||||
|
||||
// Get the session's config
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Session not found for id: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (configId) {
|
||||
case 'mode': {
|
||||
await this.setMode({
|
||||
await this.setSessionMode({
|
||||
sessionId,
|
||||
modeId: value as ApprovalModeValue,
|
||||
modeId: value as string,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'model': {
|
||||
await this.setModel({
|
||||
await this.unstable_setSessionModel({
|
||||
sessionId,
|
||||
modelId: value as string,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Unsupported configId: ${configId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Return all config options with current values
|
||||
return {
|
||||
configOptions: this.buildConfigOptions(session.getConfig()),
|
||||
};
|
||||
}
|
||||
|
||||
private buildConfigOptions(config: Config): ConfigOption[] {
|
||||
const currentApprovalMode = config.getApprovalMode();
|
||||
const allConfiguredModels = config.getAllConfiguredModels();
|
||||
const rawCurrentModelId = (config.getModel() || '').trim();
|
||||
const currentAuthType = config.getAuthType?.();
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
// Check if current model is a runtime model
|
||||
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
||||
const currentModelId = activeRuntimeSnapshot
|
||||
? formatAcpModelId(
|
||||
activeRuntimeSnapshot.id,
|
||||
activeRuntimeSnapshot.authType,
|
||||
)
|
||||
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
await session.cancelPendingPrompt();
|
||||
}
|
||||
|
||||
// Build mode config option
|
||||
const modeOptions = APPROVAL_MODES.map((mode) => ({
|
||||
value: mode,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
async extMethod(
|
||||
method: string,
|
||||
_params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
|
||||
const modeConfigOption: ConfigOption = {
|
||||
id: 'mode',
|
||||
name: 'Mode',
|
||||
description: 'Session permission mode',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: currentApprovalMode,
|
||||
options: modeOptions,
|
||||
// --- private helpers ---
|
||||
|
||||
private async newSessionConfig(
|
||||
cwd: string,
|
||||
mcpServers: McpServer[],
|
||||
sessionId?: string,
|
||||
): Promise<Config> {
|
||||
const mergedMcpServers = { ...this.settings.merged.mcpServers };
|
||||
|
||||
for (const server of mcpServers) {
|
||||
const stdioServer = toStdioServer(server);
|
||||
if (!stdioServer) continue;
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const { name: envName, value } of stdioServer.env) {
|
||||
env[envName] = value;
|
||||
}
|
||||
mergedMcpServers[stdioServer.name] = new MCPServerConfig(
|
||||
stdioServer.command,
|
||||
stdioServer.args,
|
||||
env,
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
|
||||
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
|
||||
const argvForSession = {
|
||||
...this.argv,
|
||||
resume: sessionId,
|
||||
continue: false,
|
||||
};
|
||||
|
||||
// Build model config option
|
||||
const modelOptions = allConfiguredModels.map((model) => {
|
||||
const effectiveModelId =
|
||||
model.isRuntimeModel && model.runtimeSnapshotId
|
||||
? model.runtimeSnapshotId
|
||||
: model.id;
|
||||
return {
|
||||
value: formatAcpModelId(effectiveModelId, model.authType),
|
||||
name: model.label,
|
||||
description: model.description ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
const modelConfigOption: ConfigOption = {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'AI model to use',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: currentModelId,
|
||||
options: modelOptions,
|
||||
};
|
||||
|
||||
return [modeConfigOption, modelConfigOption];
|
||||
const config = await loadCliConfig(settings, argvForSession, cwd);
|
||||
await config.initialize();
|
||||
return config;
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = config.getModelsConfig().getCurrentAuthType();
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired(
|
||||
throw RequestError.authRequired(
|
||||
{ authMethods: this.pickAuthMethodsForAuthRequired() },
|
||||
'Use Qwen Code CLI to authenticate first.',
|
||||
this.pickAuthMethodsForAuthRequired(),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use true for the second argument to ensure only cached credentials are used
|
||||
await config.refreshAuth(selectedType, true);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired(
|
||||
throw RequestError.authRequired(
|
||||
{
|
||||
authMethods: this.pickAuthMethodsForAuthRequired(selectedType, e),
|
||||
},
|
||||
'Authentication failed: ' + (e as Error).message,
|
||||
this.pickAuthMethodsForAuthRequired(selectedType, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -417,7 +400,7 @@ class GeminiAgent {
|
|||
private pickAuthMethodsForAuthRequired(
|
||||
selectedType?: AuthType | string,
|
||||
error?: unknown,
|
||||
): acp.AuthMethod[] {
|
||||
): AuthMethod[] {
|
||||
const authMethods = buildAuthMethods();
|
||||
const errorMessage = this.extractErrorMessage(error);
|
||||
if (
|
||||
|
|
@ -425,25 +408,21 @@ class GeminiAgent {
|
|||
errorMessage?.includes('Qwen OAuth')
|
||||
) {
|
||||
const qwenOAuthMethods = authMethods.filter(
|
||||
(method) => method.id === AuthType.QWEN_OAUTH,
|
||||
(m) => m.id === AuthType.QWEN_OAUTH,
|
||||
);
|
||||
return qwenOAuthMethods.length ? qwenOAuthMethods : authMethods;
|
||||
}
|
||||
|
||||
if (selectedType) {
|
||||
const matchedMethods = authMethods.filter(
|
||||
(method) => method.id === selectedType,
|
||||
);
|
||||
return matchedMethods.length ? matchedMethods : authMethods;
|
||||
const matched = authMethods.filter((m) => m.id === selectedType);
|
||||
return matched.length ? matched : authMethods;
|
||||
}
|
||||
|
||||
return authMethods;
|
||||
}
|
||||
|
||||
private extractErrorMessage(error?: unknown): string | undefined {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
|
|
@ -452,19 +431,15 @@ class GeminiAgent {
|
|||
) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
if (typeof error === 'string') return error;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private setupFileSystem(config: Config): void {
|
||||
if (!this.clientCapabilities?.fs) {
|
||||
return;
|
||||
}
|
||||
if (!this.clientCapabilities?.fs) return;
|
||||
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.client,
|
||||
this.connection,
|
||||
config.getSessionId(),
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
|
|
@ -479,26 +454,17 @@ class GeminiAgent {
|
|||
const sessionId = config.getSessionId();
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
// Use GeminiClient to manage chat lifecycle properly
|
||||
// This ensures geminiClient.chat is in sync with the session's chat
|
||||
//
|
||||
// Note: When loading a session, config.initialize() has already been called
|
||||
// in newSessionConfig(), which in turn calls geminiClient.initialize().
|
||||
// The GeminiClient.initialize() method checks config.getResumedSessionData()
|
||||
// and automatically loads the conversation history into the chat instance.
|
||||
// So we only need to initialize if it hasn't been done yet.
|
||||
if (!geminiClient.isInitialized()) {
|
||||
await geminiClient.initialize();
|
||||
}
|
||||
|
||||
// Now get the chat instance that's managed by GeminiClient
|
||||
const chat = geminiClient.getChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.client,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
|
@ -514,9 +480,7 @@ class GeminiAgent {
|
|||
return session;
|
||||
}
|
||||
|
||||
private buildAvailableModels(
|
||||
config: Config,
|
||||
): acp.NewSessionResponse['models'] {
|
||||
private buildAvailableModels(config: Config): NewSessionResponse['models'] {
|
||||
const rawCurrentModelId = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
|
|
@ -525,8 +489,6 @@ class GeminiAgent {
|
|||
const currentAuthType = config.getAuthType();
|
||||
const allConfiguredModels = config.getAllConfiguredModels();
|
||||
|
||||
// Check if current model is a runtime model
|
||||
// Runtime models use $runtime|${authType}|${modelId} format
|
||||
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
||||
const currentModelId = activeRuntimeSnapshot
|
||||
? formatAcpModelId(
|
||||
|
|
@ -535,11 +497,7 @@ class GeminiAgent {
|
|||
)
|
||||
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
||||
|
||||
const availableModels = allConfiguredModels;
|
||||
|
||||
const mappedAvailableModels = availableModels.map((model) => {
|
||||
// For runtime models, use runtimeSnapshotId as modelId for ACP protocol
|
||||
// This allows ACP clients to correctly identify and switch to runtime models
|
||||
const mappedAvailableModels = allConfiguredModels.map((model) => {
|
||||
const effectiveModelId =
|
||||
model.isRuntimeModel && model.runtimeSnapshotId
|
||||
? model.runtimeSnapshotId
|
||||
|
|
@ -561,7 +519,7 @@ class GeminiAgent {
|
|||
};
|
||||
}
|
||||
|
||||
private buildModesData(config: Config): acp.ModesData {
|
||||
private buildModesData(config: Config): SessionModeState {
|
||||
const currentApprovalMode = config.getApprovalMode();
|
||||
|
||||
const availableModes = APPROVAL_MODES.map((mode) => ({
|
||||
|
|
@ -576,14 +534,66 @@ class GeminiAgent {
|
|||
};
|
||||
}
|
||||
|
||||
private buildConfigOptions(config: Config): SessionConfigOption[] {
|
||||
const currentApprovalMode = config.getApprovalMode();
|
||||
const allConfiguredModels = config.getAllConfiguredModels();
|
||||
const rawCurrentModelId = (config.getModel() || '').trim();
|
||||
const currentAuthType = config.getAuthType?.();
|
||||
|
||||
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
||||
const currentModelId = activeRuntimeSnapshot
|
||||
? formatAcpModelId(
|
||||
activeRuntimeSnapshot.id,
|
||||
activeRuntimeSnapshot.authType,
|
||||
)
|
||||
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
||||
|
||||
const modeOptions = APPROVAL_MODES.map((mode) => ({
|
||||
value: mode,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
|
||||
const modeConfigOption: SessionConfigOption = {
|
||||
id: 'mode',
|
||||
name: 'Mode',
|
||||
description: 'Session permission mode',
|
||||
category: 'mode',
|
||||
type: 'select' as const,
|
||||
currentValue: currentApprovalMode,
|
||||
options: modeOptions,
|
||||
};
|
||||
|
||||
const modelOptions = allConfiguredModels.map((model) => {
|
||||
const effectiveModelId =
|
||||
model.isRuntimeModel && model.runtimeSnapshotId
|
||||
? model.runtimeSnapshotId
|
||||
: model.id;
|
||||
return {
|
||||
value: formatAcpModelId(effectiveModelId, model.authType),
|
||||
name: model.label,
|
||||
description: model.description ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
const modelConfigOption: SessionConfigOption = {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'AI model to use',
|
||||
category: 'model',
|
||||
type: 'select' as const,
|
||||
currentValue: currentModelId,
|
||||
options: modelOptions,
|
||||
};
|
||||
|
||||
return [modeConfigOption, modelConfigOption];
|
||||
}
|
||||
|
||||
private formatCurrentModelId(
|
||||
baseModelId: string,
|
||||
authType?: AuthType,
|
||||
): string {
|
||||
if (!baseModelId) {
|
||||
return baseModelId;
|
||||
}
|
||||
|
||||
if (!baseModelId) return baseModelId;
|
||||
return authType ? formatAcpModelId(baseModelId, authType) : baseModelId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { AuthMethod } from './schema.js';
|
||||
import type { AuthMethod } from '@agentclientprotocol/sdk';
|
||||
|
||||
export function buildAuthMethods(): AuthMethod[] {
|
||||
return [
|
||||
|
|
@ -13,16 +13,20 @@ export function buildAuthMethods(): AuthMethod[] {
|
|||
id: AuthType.USE_OPENAI,
|
||||
name: 'Use OpenAI API key',
|
||||
description: 'Requires setting the `OPENAI_API_KEY` environment variable',
|
||||
type: 'terminal',
|
||||
args: ['--auth-type=openai'],
|
||||
_meta: {
|
||||
type: 'terminal',
|
||||
args: ['--auth-type=openai'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AuthType.QWEN_OAUTH,
|
||||
name: 'Qwen OAuth',
|
||||
description:
|
||||
'OAuth authentication for Qwen models with free daily requests',
|
||||
type: 'terminal',
|
||||
args: ['--auth-type=qwen-oauth'],
|
||||
_meta: {
|
||||
type: 'terminal',
|
||||
args: ['--auth-type=qwen-oauth'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,708 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AGENT_METHODS = {
|
||||
authenticate: 'authenticate',
|
||||
initialize: 'initialize',
|
||||
session_cancel: 'session/cancel',
|
||||
session_load: 'session/load',
|
||||
session_new: 'session/new',
|
||||
session_prompt: 'session/prompt',
|
||||
session_list: 'session/list',
|
||||
session_set_mode: 'session/set_mode',
|
||||
session_set_model: 'session/set_model',
|
||||
session_set_config_option: 'session/set_config_option',
|
||||
};
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
fs_read_text_file: 'fs/read_text_file',
|
||||
fs_write_text_file: 'fs/write_text_file',
|
||||
authenticate_update: 'authenticate/update',
|
||||
session_request_permission: 'session/request_permission',
|
||||
session_update: 'session/update',
|
||||
};
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export type WriteTextFileRequest = z.infer<typeof writeTextFileRequestSchema>;
|
||||
|
||||
export type ReadTextFileRequest = z.infer<typeof readTextFileRequestSchema>;
|
||||
|
||||
export type PermissionOptionKind = z.infer<typeof permissionOptionKindSchema>;
|
||||
|
||||
export type Role = z.infer<typeof roleSchema>;
|
||||
|
||||
export type TextResourceContents = z.infer<typeof textResourceContentsSchema>;
|
||||
|
||||
export type BlobResourceContents = z.infer<typeof blobResourceContentsSchema>;
|
||||
|
||||
export type ToolKind = z.infer<typeof toolKindSchema>;
|
||||
|
||||
export type ToolCallStatus = z.infer<typeof toolCallStatusSchema>;
|
||||
|
||||
export type WriteTextFileResponse = z.infer<typeof writeTextFileResponseSchema>;
|
||||
|
||||
export type ReadTextFileResponse = z.infer<typeof readTextFileResponseSchema>;
|
||||
|
||||
export type RequestPermissionOutcome = z.infer<
|
||||
typeof requestPermissionOutcomeSchema
|
||||
>;
|
||||
export type SessionListItem = z.infer<typeof sessionListItemSchema>;
|
||||
export type ListSessionsRequest = z.infer<typeof listSessionsRequestSchema>;
|
||||
export type ListSessionsResponse = z.infer<typeof listSessionsResponseSchema>;
|
||||
|
||||
export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
|
||||
|
||||
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
|
||||
|
||||
// Note: NewSessionResponse type is defined later after newSessionResponseSchema
|
||||
|
||||
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
|
||||
|
||||
export type StopReason = z.infer<typeof stopReasonSchema>;
|
||||
|
||||
export type PromptResponse = z.infer<typeof promptResponseSchema>;
|
||||
|
||||
export type ToolCallLocation = z.infer<typeof toolCallLocationSchema>;
|
||||
|
||||
export type PlanEntry = z.infer<typeof planEntrySchema>;
|
||||
|
||||
export type PermissionOption = z.infer<typeof permissionOptionSchema>;
|
||||
|
||||
export type Annotations = z.infer<typeof annotationsSchema>;
|
||||
|
||||
export type RequestPermissionResponse = z.infer<
|
||||
typeof requestPermissionResponseSchema
|
||||
>;
|
||||
|
||||
export type FileSystemCapability = z.infer<typeof fileSystemCapabilitySchema>;
|
||||
|
||||
export type EnvVariable = z.infer<typeof envVariableSchema>;
|
||||
|
||||
export type McpServer = z.infer<typeof mcpServerSchema>;
|
||||
|
||||
export type AgentCapabilities = z.infer<typeof agentCapabilitiesSchema>;
|
||||
|
||||
export type AuthMethod = z.infer<typeof authMethodSchema>;
|
||||
|
||||
export type ModeInfo = z.infer<typeof modeInfoSchema>;
|
||||
|
||||
export type ModesData = z.infer<typeof modesDataSchema>;
|
||||
|
||||
export type AgentInfo = z.infer<typeof agentInfoSchema>;
|
||||
export type ModelInfo = z.infer<typeof modelInfoSchema>;
|
||||
|
||||
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
|
||||
|
||||
export type ClientResponse = z.infer<typeof clientResponseSchema>;
|
||||
|
||||
export type ClientNotification = z.infer<typeof clientNotificationSchema>;
|
||||
|
||||
export type EmbeddedResourceResource = z.infer<
|
||||
typeof embeddedResourceResourceSchema
|
||||
>;
|
||||
|
||||
export type NewSessionRequest = z.infer<typeof newSessionRequestSchema>;
|
||||
|
||||
export type LoadSessionRequest = z.infer<typeof loadSessionRequestSchema>;
|
||||
|
||||
export type InitializeResponse = z.infer<typeof initializeResponseSchema>;
|
||||
|
||||
export type ContentBlock = z.infer<typeof contentBlockSchema>;
|
||||
|
||||
export type ToolCallContent = z.infer<typeof toolCallContentSchema>;
|
||||
|
||||
export type ToolCall = z.infer<typeof toolCallSchema>;
|
||||
|
||||
export type ClientCapabilities = z.infer<typeof clientCapabilitiesSchema>;
|
||||
|
||||
export type PromptRequest = z.infer<typeof promptRequestSchema>;
|
||||
|
||||
export type SessionUpdate = z.infer<typeof sessionUpdateSchema>;
|
||||
|
||||
export type AgentResponse = z.infer<typeof agentResponseSchema>;
|
||||
|
||||
export type RequestPermissionRequest = z.infer<
|
||||
typeof requestPermissionRequestSchema
|
||||
>;
|
||||
|
||||
export type InitializeRequest = z.infer<typeof initializeRequestSchema>;
|
||||
|
||||
export type SessionNotification = z.infer<typeof sessionNotificationSchema>;
|
||||
|
||||
export type ClientRequest = z.infer<typeof clientRequestSchema>;
|
||||
|
||||
export type AgentRequest = z.infer<typeof agentRequestSchema>;
|
||||
|
||||
export type AgentNotification = z.infer<typeof agentNotificationSchema>;
|
||||
|
||||
export type ApprovalModeValue = z.infer<typeof approvalModeValueSchema>;
|
||||
|
||||
export type SetModeRequest = z.infer<typeof setModeRequestSchema>;
|
||||
|
||||
export type SetModeResponse = z.infer<typeof setModeResponseSchema>;
|
||||
|
||||
export type AvailableCommandInput = z.infer<typeof availableCommandInputSchema>;
|
||||
|
||||
export type AvailableCommand = z.infer<typeof availableCommandSchema>;
|
||||
|
||||
export type AvailableCommandsUpdate = z.infer<
|
||||
typeof availableCommandsUpdateSchema
|
||||
>;
|
||||
|
||||
export const writeTextFileRequestSchema = z.object({
|
||||
content: z.string(),
|
||||
path: z.string(),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const readTextFileRequestSchema = z.object({
|
||||
limit: z.number().optional().nullable(),
|
||||
line: z.number().optional().nullable(),
|
||||
path: z.string(),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const permissionOptionKindSchema = z.union([
|
||||
z.literal('allow_once'),
|
||||
z.literal('allow_always'),
|
||||
z.literal('reject_once'),
|
||||
z.literal('reject_always'),
|
||||
]);
|
||||
|
||||
export const roleSchema = z.union([z.literal('assistant'), z.literal('user')]);
|
||||
|
||||
export const textResourceContentsSchema = z.object({
|
||||
mimeType: z.string().optional().nullable(),
|
||||
text: z.string(),
|
||||
uri: z.string(),
|
||||
});
|
||||
|
||||
export const blobResourceContentsSchema = z.object({
|
||||
blob: z.string(),
|
||||
mimeType: z.string().optional().nullable(),
|
||||
uri: z.string(),
|
||||
});
|
||||
|
||||
export const toolKindSchema = z.union([
|
||||
z.literal('read'),
|
||||
z.literal('edit'),
|
||||
z.literal('delete'),
|
||||
z.literal('move'),
|
||||
z.literal('search'),
|
||||
z.literal('execute'),
|
||||
z.literal('think'),
|
||||
z.literal('fetch'),
|
||||
z.literal('switch_mode'),
|
||||
z.literal('other'),
|
||||
]);
|
||||
|
||||
export const toolCallStatusSchema = z.union([
|
||||
z.literal('pending'),
|
||||
z.literal('in_progress'),
|
||||
z.literal('completed'),
|
||||
z.literal('failed'),
|
||||
]);
|
||||
|
||||
export const writeTextFileResponseSchema = z.null();
|
||||
|
||||
export const readTextFileResponseSchema = z.object({
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const requestPermissionOutcomeSchema = z.union([
|
||||
z.object({
|
||||
outcome: z.literal('cancelled'),
|
||||
}),
|
||||
z.object({
|
||||
optionId: z.string(),
|
||||
outcome: z.literal('selected'),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const cancelNotificationSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const approvalModeValueSchema = z.union([
|
||||
z.literal('plan'),
|
||||
z.literal('default'),
|
||||
z.literal('auto-edit'),
|
||||
z.literal('yolo'),
|
||||
]);
|
||||
|
||||
export const setModeRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export const setModeResponseSchema = z.object({
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export const authenticateRequestSchema = z.object({
|
||||
methodId: z.string(),
|
||||
});
|
||||
|
||||
export const authenticateUpdateSchema = z.object({
|
||||
_meta: z.object({
|
||||
authUri: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
|
||||
|
||||
export const acpMetaSchema = z.record(z.unknown()).nullable().optional();
|
||||
|
||||
export const modelIdSchema = z.string();
|
||||
|
||||
export const modelInfoSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
description: z.string().nullable().optional(),
|
||||
modelId: modelIdSchema,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const setModelRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const setModelResponseSchema = z.object({
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export type SetModelRequest = z.infer<typeof setModelRequestSchema>;
|
||||
export type SetModelResponse = z.infer<typeof setModelResponseSchema>;
|
||||
|
||||
export const sessionModelStateSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
availableModels: z.array(modelInfoSchema),
|
||||
currentModelId: modelIdSchema,
|
||||
});
|
||||
|
||||
// Note: newSessionResponseSchema is defined later in the file after modesDataSchema
|
||||
|
||||
export const loadSessionResponseSchema = z.null();
|
||||
|
||||
export const sessionListItemSchema = z.object({
|
||||
cwd: z.string(),
|
||||
filePath: z.string().optional(),
|
||||
gitBranch: z.string().optional(),
|
||||
messageCount: z.number().optional(),
|
||||
mtime: z.number().optional(),
|
||||
prompt: z.string().optional(),
|
||||
sessionId: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
title: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const listSessionsResponseSchema = z.object({
|
||||
hasMore: z.boolean().optional(),
|
||||
items: z.array(sessionListItemSchema).optional(),
|
||||
nextCursor: z.number().optional(),
|
||||
sessions: z.array(sessionListItemSchema),
|
||||
});
|
||||
|
||||
export const listSessionsRequestSchema = z.object({
|
||||
cursor: z.number().optional(),
|
||||
cwd: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
});
|
||||
|
||||
export const stopReasonSchema = z.union([
|
||||
z.literal('end_turn'),
|
||||
z.literal('max_tokens'),
|
||||
z.literal('refusal'),
|
||||
z.literal('cancelled'),
|
||||
]);
|
||||
|
||||
export const promptResponseSchema = z.object({
|
||||
stopReason: stopReasonSchema,
|
||||
});
|
||||
|
||||
export const toolCallLocationSchema = z.object({
|
||||
line: z.number().optional().nullable(),
|
||||
path: z.string(),
|
||||
});
|
||||
|
||||
export const planEntrySchema = z.object({
|
||||
content: z.string(),
|
||||
priority: z.union([z.literal('high'), z.literal('medium'), z.literal('low')]),
|
||||
status: z.union([
|
||||
z.literal('pending'),
|
||||
z.literal('in_progress'),
|
||||
z.literal('completed'),
|
||||
]),
|
||||
});
|
||||
|
||||
export const permissionOptionSchema = z.object({
|
||||
kind: permissionOptionKindSchema,
|
||||
name: z.string(),
|
||||
optionId: z.string(),
|
||||
});
|
||||
|
||||
export const annotationsSchema = z.object({
|
||||
audience: z.array(roleSchema).optional().nullable(),
|
||||
lastModified: z.string().optional().nullable(),
|
||||
priority: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export const usageSchema = z.object({
|
||||
promptTokens: z.number().optional().nullable(),
|
||||
completionTokens: z.number().optional().nullable(),
|
||||
thoughtsTokens: z.number().optional().nullable(),
|
||||
totalTokens: z.number().optional().nullable(),
|
||||
cachedTokens: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type Usage = z.infer<typeof usageSchema>;
|
||||
|
||||
export const sessionUpdateMetaSchema = z.object({
|
||||
usage: usageSchema.optional().nullable(),
|
||||
durationMs: z.number().optional().nullable(),
|
||||
toolName: z.string().optional().nullable(),
|
||||
parentToolCallId: z.string().optional().nullable(),
|
||||
subagentType: z.string().optional().nullable(),
|
||||
/** Server-side timestamp (ms since epoch) for correct message ordering */
|
||||
timestamp: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
});
|
||||
|
||||
export const fileSystemCapabilitySchema = z.object({
|
||||
readTextFile: z.boolean(),
|
||||
writeTextFile: z.boolean(),
|
||||
});
|
||||
|
||||
export const envVariableSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const mcpServerSchema = z.object({
|
||||
args: z.array(z.string()),
|
||||
command: z.string(),
|
||||
env: z.array(envVariableSchema),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const promptCapabilitiesSchema = z.object({
|
||||
audio: z.boolean().optional(),
|
||||
embeddedContext: z.boolean().optional(),
|
||||
image: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const agentCapabilitiesSchema = z.object({
|
||||
loadSession: z.boolean().optional(),
|
||||
promptCapabilities: promptCapabilitiesSchema.optional(),
|
||||
sessionCapabilities: z
|
||||
.object({
|
||||
list: z.object({}).optional(),
|
||||
resume: z.object({}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const authMethodSchema = z.object({
|
||||
args: z.array(z.string()).optional(),
|
||||
description: z.string().nullable(),
|
||||
env: z.record(z.string()).optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const clientResponseSchema = z.union([
|
||||
writeTextFileResponseSchema,
|
||||
readTextFileResponseSchema,
|
||||
requestPermissionResponseSchema,
|
||||
]);
|
||||
|
||||
export const clientNotificationSchema = cancelNotificationSchema;
|
||||
|
||||
export const embeddedResourceResourceSchema = z.union([
|
||||
textResourceContentsSchema,
|
||||
blobResourceContentsSchema,
|
||||
]);
|
||||
|
||||
export const newSessionRequestSchema = z.object({
|
||||
cwd: z.string(),
|
||||
mcpServers: z.array(mcpServerSchema),
|
||||
});
|
||||
|
||||
export const loadSessionRequestSchema = z.object({
|
||||
cwd: z.string(),
|
||||
mcpServers: z.array(mcpServerSchema),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const modeInfoSchema = z.object({
|
||||
id: approvalModeValueSchema,
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
});
|
||||
|
||||
export const modesDataSchema = z.object({
|
||||
currentModeId: approvalModeValueSchema,
|
||||
availableModes: z.array(modeInfoSchema),
|
||||
});
|
||||
|
||||
export const configOptionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
category: z.string(),
|
||||
type: z.string(),
|
||||
currentValue: z.string(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type ConfigOption = z.infer<typeof configOptionSchema>;
|
||||
|
||||
export const setConfigOptionRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
configId: z.string(),
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
export const setConfigOptionResponseSchema = z.object({
|
||||
configOptions: z.array(configOptionSchema),
|
||||
});
|
||||
|
||||
export type SetConfigOptionRequest = z.infer<
|
||||
typeof setConfigOptionRequestSchema
|
||||
>;
|
||||
export type SetConfigOptionResponse = z.infer<
|
||||
typeof setConfigOptionResponseSchema
|
||||
>;
|
||||
|
||||
// newSessionResponseSchema includes modes and configOptions for ACP/Zed integration
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
models: sessionModelStateSchema,
|
||||
modes: modesDataSchema,
|
||||
configOptions: z.array(configOptionSchema),
|
||||
});
|
||||
|
||||
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
|
||||
|
||||
export const agentInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
export const initializeResponseSchema = z.object({
|
||||
agentCapabilities: agentCapabilitiesSchema,
|
||||
agentInfo: agentInfoSchema,
|
||||
authMethods: z.array(authMethodSchema),
|
||||
modes: modesDataSchema,
|
||||
protocolVersion: z.number(),
|
||||
});
|
||||
|
||||
export const contentBlockSchema = z.union([
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
text: z.string(),
|
||||
type: z.literal('text'),
|
||||
}),
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
data: z.string(),
|
||||
mimeType: z.string(),
|
||||
type: z.literal('image'),
|
||||
}),
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
data: z.string(),
|
||||
mimeType: z.string(),
|
||||
type: z.literal('audio'),
|
||||
}),
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
mimeType: z.string().optional().nullable(),
|
||||
name: z.string(),
|
||||
size: z.number().optional().nullable(),
|
||||
title: z.string().optional().nullable(),
|
||||
type: z.literal('resource_link'),
|
||||
uri: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
annotations: annotationsSchema.optional().nullable(),
|
||||
resource: embeddedResourceResourceSchema,
|
||||
type: z.literal('resource'),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const toolCallContentSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
type: z.literal('content'),
|
||||
}),
|
||||
z.object({
|
||||
newText: z.string(),
|
||||
oldText: z.string().nullable(),
|
||||
path: z.string(),
|
||||
type: z.literal('diff'),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const toolCallSchema = z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
kind: toolKindSchema,
|
||||
locations: z.array(toolCallLocationSchema).optional(),
|
||||
rawInput: z.unknown().optional(),
|
||||
status: toolCallStatusSchema,
|
||||
title: z.string(),
|
||||
toolCallId: z.string(),
|
||||
});
|
||||
|
||||
export const clientCapabilitiesSchema = z.object({
|
||||
fs: fileSystemCapabilitySchema,
|
||||
});
|
||||
|
||||
export const promptRequestSchema = z.object({
|
||||
prompt: z.array(contentBlockSchema),
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandInputSchema = z.object({
|
||||
hint: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandSchema = z.object({
|
||||
description: z.string(),
|
||||
input: availableCommandInputSchema.nullable().optional(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandsUpdateSchema = z.object({
|
||||
availableCommands: z.array(availableCommandSchema),
|
||||
sessionUpdate: z.literal('available_commands_update'),
|
||||
});
|
||||
|
||||
export const currentModeUpdateSchema = z.object({
|
||||
sessionUpdate: z.literal('current_mode_update'),
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
|
||||
|
||||
export const currentModelUpdateSchema = z.object({
|
||||
sessionUpdate: z.literal('current_model_update'),
|
||||
model: modelInfoSchema,
|
||||
});
|
||||
|
||||
export type CurrentModelUpdate = z.infer<typeof currentModelUpdateSchema>;
|
||||
|
||||
export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('user_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
kind: toolKindSchema,
|
||||
locations: z.array(toolCallLocationSchema).optional(),
|
||||
rawInput: z.unknown().optional(),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
sessionUpdate: z.literal('tool_call'),
|
||||
status: toolCallStatusSchema,
|
||||
title: z.string(),
|
||||
toolCallId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional().nullable(),
|
||||
kind: toolKindSchema.optional().nullable(),
|
||||
locations: z.array(toolCallLocationSchema).optional().nullable(),
|
||||
rawInput: z.unknown().optional(),
|
||||
rawOutput: z.unknown().optional(),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
sessionUpdate: z.literal('tool_call_update'),
|
||||
status: toolCallStatusSchema.optional().nullable(),
|
||||
title: z.string().optional().nullable(),
|
||||
toolCallId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
entries: z.array(planEntrySchema),
|
||||
sessionUpdate: z.literal('plan'),
|
||||
}),
|
||||
currentModeUpdateSchema,
|
||||
currentModelUpdateSchema,
|
||||
availableCommandsUpdateSchema,
|
||||
]);
|
||||
|
||||
export const agentResponseSchema = z.union([
|
||||
initializeResponseSchema,
|
||||
newSessionResponseSchema,
|
||||
loadSessionResponseSchema,
|
||||
promptResponseSchema,
|
||||
listSessionsResponseSchema,
|
||||
setModeResponseSchema,
|
||||
setModelResponseSchema,
|
||||
]);
|
||||
|
||||
export const requestPermissionRequestSchema = z.object({
|
||||
options: z.array(permissionOptionSchema),
|
||||
sessionId: z.string(),
|
||||
toolCall: toolCallSchema,
|
||||
});
|
||||
|
||||
export const initializeRequestSchema = z.object({
|
||||
clientCapabilities: clientCapabilitiesSchema,
|
||||
protocolVersion: z.number(),
|
||||
});
|
||||
|
||||
export const sessionNotificationSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
update: sessionUpdateSchema,
|
||||
});
|
||||
|
||||
export const clientRequestSchema = z.union([
|
||||
writeTextFileRequestSchema,
|
||||
readTextFileRequestSchema,
|
||||
requestPermissionRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentRequestSchema = z.union([
|
||||
initializeRequestSchema,
|
||||
authenticateRequestSchema,
|
||||
newSessionRequestSchema,
|
||||
loadSessionRequestSchema,
|
||||
promptRequestSchema,
|
||||
listSessionsRequestSchema,
|
||||
setModeRequestSchema,
|
||||
setModelRequestSchema,
|
||||
setConfigOptionRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentNotificationSchema = sessionNotificationSchema;
|
||||
|
|
@ -7,10 +7,16 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
|
||||
const RESOURCE_NOT_FOUND_CODE = -32002;
|
||||
const INTERNAL_ERROR_CODE = -32603;
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
readTextFileWithInfo: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: '', encoding: 'utf-8', bom: false }),
|
||||
writeTextFile: vi.fn(),
|
||||
detectFileBOM: vi.fn().mockResolvedValue(false),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
|
|
@ -23,7 +29,7 @@ describe('AcpFileSystemService', () => {
|
|||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: '\ufeff// BOM file' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
|
|
@ -37,7 +43,6 @@ describe('AcpFileSystemService', () => {
|
|||
expect(client.readTextFile).toHaveBeenCalledWith({
|
||||
path: '/test/file.txt',
|
||||
sessionId: 'session-1',
|
||||
line: null,
|
||||
limit: 1,
|
||||
});
|
||||
});
|
||||
|
|
@ -45,7 +50,7 @@ describe('AcpFileSystemService', () => {
|
|||
it('detects no BOM through ACP client when content does not start with U+FEFF', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: '// No BOM file' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
|
|
@ -61,7 +66,7 @@ describe('AcpFileSystemService', () => {
|
|||
it('falls back to local filesystem when ACP client fails', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockRejectedValue(new Error('Network error')),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
|
|
@ -83,7 +88,7 @@ describe('AcpFileSystemService', () => {
|
|||
it('falls back to local filesystem when readTextFile capability is disabled', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn(),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
|
|
@ -107,12 +112,12 @@ describe('AcpFileSystemService', () => {
|
|||
describe('readTextFile ENOENT handling', () => {
|
||||
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
|
||||
const resourceNotFoundError = {
|
||||
code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND,
|
||||
code: RESOURCE_NOT_FOUND_CODE,
|
||||
message: 'File not found',
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
|
|
@ -130,12 +135,12 @@ describe('AcpFileSystemService', () => {
|
|||
|
||||
it('re-throws other errors unchanged', async () => {
|
||||
const otherError = {
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: 'Internal error',
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockRejectedValue(otherError),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
|
|
@ -145,7 +150,7 @@ describe('AcpFileSystemService', () => {
|
|||
);
|
||||
|
||||
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: 'Internal error',
|
||||
});
|
||||
});
|
||||
|
|
@ -153,7 +158,7 @@ describe('AcpFileSystemService', () => {
|
|||
it('uses fallback when readTextFile capability is disabled', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn(),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
FileSystemCapability,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
import type {
|
||||
FileReadResult,
|
||||
FileSystemService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
const RESOURCE_NOT_FOUND_CODE = -32002;
|
||||
|
||||
/**
|
||||
* ACP client-based implementation of FileSystemService
|
||||
*/
|
||||
export class AcpFileSystemService implements FileSystemService {
|
||||
constructor(
|
||||
private readonly client: acp.Client,
|
||||
private readonly connection: AgentSideConnection,
|
||||
private readonly sessionId: string,
|
||||
private readonly capabilities: acp.FileSystemCapability,
|
||||
private readonly capabilities: FileSystemCapability,
|
||||
private readonly fallback: FileSystemService,
|
||||
) {}
|
||||
|
||||
|
|
@ -26,19 +31,19 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
|
||||
let response: { content: string };
|
||||
try {
|
||||
response = await this.client.readTextFile({
|
||||
response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
error instanceof RequestError
|
||||
? error.code
|
||||
: typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
|
||||
if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) {
|
||||
if (errorCode === RESOURCE_NOT_FOUND_CODE) {
|
||||
const err = new Error(
|
||||
`File not found: ${filePath}`,
|
||||
) as NodeJS.ErrnoException;
|
||||
|
|
@ -54,19 +59,24 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
return response.content;
|
||||
}
|
||||
|
||||
async readTextFileWithInfo(filePath: string): Promise<FileReadResult> {
|
||||
// ACP protocol does not expose encoding metadata; delegate to the local
|
||||
// fallback which performs a single-pass read with encoding detection.
|
||||
return this.fallback.readTextFileWithInfo(filePath);
|
||||
}
|
||||
|
||||
async writeTextFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
options?: { bom?: boolean },
|
||||
options?: { bom?: boolean; encoding?: string },
|
||||
): Promise<void> {
|
||||
if (!this.capabilities.writeTextFile) {
|
||||
return this.fallback.writeTextFile(filePath, content, options);
|
||||
}
|
||||
|
||||
// Prepend BOM character if requested
|
||||
const finalContent = options?.bom ? '\uFEFF' + content : content;
|
||||
|
||||
await this.client.writeTextFile({
|
||||
await this.connection.writeTextFile({
|
||||
path: filePath,
|
||||
content: finalContent,
|
||||
sessionId: this.sessionId,
|
||||
|
|
@ -74,17 +84,13 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
}
|
||||
|
||||
async detectFileBOM(filePath: string): Promise<boolean> {
|
||||
// Try to detect BOM through ACP client first by reading first line
|
||||
if (this.capabilities.readTextFile) {
|
||||
try {
|
||||
const response = await this.client.readTextFile({
|
||||
const response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: 1,
|
||||
});
|
||||
// Check if content starts with BOM character (U+FEFF)
|
||||
// Use codePointAt for better Unicode support and check content length first
|
||||
return (
|
||||
response.content.length > 0 &&
|
||||
response.content.codePointAt(0) === 0xfeff
|
||||
|
|
@ -93,7 +99,6 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
// Fall through to fallback if ACP read fails
|
||||
}
|
||||
}
|
||||
// Fall back to local filesystem detection
|
||||
return this.fallback.detectFileBOM(filePath);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -464,11 +464,11 @@ describe('HistoryReplayer', () => {
|
|||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: undefined,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
cachedTokens: undefined,
|
||||
thoughtTokens: undefined,
|
||||
cachedReadTokens: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import { Session } from './Session.js';
|
|||
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import * as core from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
PromptRequest,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
|
||||
|
||||
|
|
@ -24,7 +27,7 @@ vi.mock('../../nonInteractiveCliCommands.js', () => ({
|
|||
describe('Session', () => {
|
||||
let mockChat: GeminiChat;
|
||||
let mockConfig: Config;
|
||||
let mockClient: acp.Client;
|
||||
let mockClient: AgentSideConnection;
|
||||
let mockSettings: LoadedSettings;
|
||||
let session: Session;
|
||||
let currentModel: string;
|
||||
|
|
@ -76,8 +79,8 @@ describe('Session', () => {
|
|||
requestPermission: vi.fn().mockResolvedValue({
|
||||
outcome: { outcome: 'selected', optionId: 'proceed_once' },
|
||||
}),
|
||||
sendCustomNotification: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as acp.Client;
|
||||
extNotification: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
mockSettings = {
|
||||
merged: {},
|
||||
|
|
@ -103,20 +106,19 @@ describe('Session', () => {
|
|||
['auto-edit', ApprovalMode.AUTO_EDIT],
|
||||
['yolo', ApprovalMode.YOLO],
|
||||
] as const)('maps %s mode', async (modeId, expected) => {
|
||||
const result = await session.setMode({
|
||||
await session.setMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId,
|
||||
});
|
||||
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
|
||||
expect(result).toEqual({ modeId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setModel', () => {
|
||||
it('sets model via config and returns current model', async () => {
|
||||
const requested = `qwen3-coder-plus(${AuthType.USE_OPENAI})`;
|
||||
const result = await session.setModel({
|
||||
await session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ` ${requested} `,
|
||||
});
|
||||
|
|
@ -126,10 +128,6 @@ describe('Session', () => {
|
|||
'qwen3-coder-plus',
|
||||
undefined,
|
||||
);
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
modelId: `qwen3-coder-plus(${AuthType.USE_OPENAI})`,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty/whitespace model IDs', async () => {
|
||||
|
|
@ -221,7 +219,7 @@ describe('Session', () => {
|
|||
.fn()
|
||||
.mockResolvedValue((async function* () {})());
|
||||
|
||||
const promptRequest: acp.PromptRequest = {
|
||||
const promptRequest: PromptRequest = {
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [
|
||||
{ type: 'text', text: 'Check this file' },
|
||||
|
|
|
|||
|
|
@ -36,7 +36,25 @@ import {
|
|||
readManyFiles,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import * as acp from '../acp.js';
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
import type {
|
||||
AvailableCommand,
|
||||
ContentBlock,
|
||||
EmbeddedResourceResource,
|
||||
PermissionOption,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
ToolCallContent,
|
||||
AgentSideConnection,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { z } from 'zod';
|
||||
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
|
||||
|
|
@ -45,24 +63,15 @@ import {
|
|||
getAvailableCommands,
|
||||
type NonInteractiveSlashCommandResult,
|
||||
} from '../../nonInteractiveCliCommands.js';
|
||||
import type {
|
||||
AvailableCommand,
|
||||
AvailableCommandsUpdate,
|
||||
SetModeRequest,
|
||||
SetModeResponse,
|
||||
SetModelRequest,
|
||||
SetModelResponse,
|
||||
ApprovalModeValue,
|
||||
CurrentModeUpdate,
|
||||
} from '../schema.js';
|
||||
import { isSlashCommand } from '../../ui/utils/commandUtils.js';
|
||||
import {
|
||||
formatAcpModelId,
|
||||
parseAcpModelOption,
|
||||
} from '../../utils/acpModelUtils.js';
|
||||
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
|
||||
|
||||
// Import modular session components
|
||||
import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import type {
|
||||
ApprovalModeValue,
|
||||
SessionContext,
|
||||
ToolCallStartParams,
|
||||
} from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
|
|
@ -96,7 +105,7 @@ export class Session implements SessionContext {
|
|||
id: string,
|
||||
private readonly chat: GeminiChat,
|
||||
readonly config: Config,
|
||||
private readonly client: acp.Client,
|
||||
private readonly client: AgentSideConnection,
|
||||
private readonly settings: LoadedSettings,
|
||||
) {
|
||||
this.sessionId = id;
|
||||
|
|
@ -133,7 +142,7 @@ export class Session implements SessionContext {
|
|||
this.pendingPrompt = null;
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
this.pendingPrompt?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingPrompt = pendingSend;
|
||||
|
|
@ -254,10 +263,7 @@ export class Session implements SessionContext {
|
|||
}
|
||||
} catch (error) {
|
||||
if (getErrorStatus(error) === 429) {
|
||||
throw new acp.RequestError(
|
||||
429,
|
||||
'Rate limit exceeded. Try again later.',
|
||||
);
|
||||
throw new RequestError(429, 'Rate limit exceeded. Try again later.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
|
@ -287,8 +293,8 @@ export class Session implements SessionContext {
|
|||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
|
||||
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
const params: acp.SessionNotification = {
|
||||
async sendUpdate(update: SessionUpdate): Promise<void> {
|
||||
const params: SessionNotification = {
|
||||
sessionId: this.sessionId,
|
||||
update,
|
||||
};
|
||||
|
|
@ -314,7 +320,7 @@ export class Session implements SessionContext {
|
|||
}),
|
||||
);
|
||||
|
||||
const update: AvailableCommandsUpdate = {
|
||||
const update: SessionUpdate = {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands,
|
||||
};
|
||||
|
|
@ -331,8 +337,8 @@ export class Session implements SessionContext {
|
|||
* Used by SubAgentTracker for sub-agent approval requests.
|
||||
*/
|
||||
async requestPermission(
|
||||
params: acp.RequestPermissionRequest,
|
||||
): Promise<acp.RequestPermissionResponse> {
|
||||
params: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
return this.client.requestPermission(params);
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +346,9 @@ export class Session implements SessionContext {
|
|||
* Sets the approval mode for the current session.
|
||||
* Maps ACP approval mode values to core ApprovalMode enum.
|
||||
*/
|
||||
async setMode(params: SetModeRequest): Promise<SetModeResponse> {
|
||||
async setMode(
|
||||
params: SetSessionModeRequest,
|
||||
): Promise<SetSessionModeResponse | void> {
|
||||
const modeMap: Record<ApprovalModeValue, ApprovalMode> = {
|
||||
plan: ApprovalMode.PLAN,
|
||||
default: ApprovalMode.DEFAULT,
|
||||
|
|
@ -348,21 +356,21 @@ export class Session implements SessionContext {
|
|||
yolo: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
const approvalMode = modeMap[params.modeId];
|
||||
const approvalMode = modeMap[params.modeId as ApprovalModeValue];
|
||||
this.config.setApprovalMode(approvalMode);
|
||||
|
||||
return { modeId: params.modeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model for the current session.
|
||||
* Validates the model ID and switches the model via Config.
|
||||
*/
|
||||
async setModel(params: SetModelRequest): Promise<SetModelResponse> {
|
||||
async setModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
const rawModelId = params.modelId.trim();
|
||||
|
||||
if (!rawModelId) {
|
||||
throw acp.RequestError.invalidParams('modelId cannot be empty');
|
||||
throw RequestError.invalidParams(undefined, 'modelId cannot be empty');
|
||||
}
|
||||
|
||||
const parsed = parseAcpModelOption(rawModelId);
|
||||
|
|
@ -370,7 +378,8 @@ export class Session implements SessionContext {
|
|||
const selectedAuthType = parsed.authType ?? previousAuthType;
|
||||
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`authType cannot be determined for modelId "${parsed.modelId}"`,
|
||||
);
|
||||
}
|
||||
|
|
@ -383,14 +392,6 @@ export class Session implements SessionContext {
|
|||
? { requireCachedCredentials: true }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Get updated model info
|
||||
const currentModel = this.config.getModel();
|
||||
const currentAuthType = this.config.getAuthType?.() ?? selectedAuthType;
|
||||
|
||||
return {
|
||||
modelId: formatAcpModelId(currentModel, currentAuthType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -413,9 +414,9 @@ export class Session implements SessionContext {
|
|||
break;
|
||||
}
|
||||
|
||||
const update: CurrentModeUpdate = {
|
||||
const update: SessionUpdate = {
|
||||
sessionUpdate: 'current_mode_update',
|
||||
modeId: newModeId,
|
||||
currentModeId: newModeId,
|
||||
};
|
||||
|
||||
await this.sendUpdate(update);
|
||||
|
|
@ -512,13 +513,27 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
const confirmationDetails =
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
// In YOLO mode, auto-approve everything except ask_user_question
|
||||
// (the user must always have a chance to respond to questions)
|
||||
const isAskUserQuestionTool =
|
||||
confirmationDetails && confirmationDetails.type === 'ask_user_question';
|
||||
const effectiveConfirmationDetails =
|
||||
this.config.getApprovalMode() === ApprovalMode.YOLO &&
|
||||
!isAskUserQuestionTool
|
||||
? false
|
||||
: confirmationDetails;
|
||||
|
||||
// Check for plan mode enforcement - block non-read-only tools
|
||||
// but allow ask_user_question so users can answer clarification questions
|
||||
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
|
||||
if (isPlanMode && !isExitPlanModeTool && confirmationDetails) {
|
||||
if (
|
||||
isPlanMode &&
|
||||
!isExitPlanModeTool &&
|
||||
!isAskUserQuestionTool &&
|
||||
effectiveConfirmationDetails
|
||||
) {
|
||||
// In plan mode, block any tool that requires confirmation (write operations)
|
||||
return errorResponse(
|
||||
new Error(
|
||||
|
|
@ -528,25 +543,25 @@ export class Session implements SessionContext {
|
|||
);
|
||||
}
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
if (effectiveConfirmationDetails) {
|
||||
const content: ToolCallContent[] = [];
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (effectiveConfirmationDetails.type === 'edit') {
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
path: effectiveConfirmationDetails.fileName,
|
||||
oldText: effectiveConfirmationDetails.originalContent,
|
||||
newText: effectiveConfirmationDetails.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
// Add plan content for exit_plan_mode
|
||||
if (confirmationDetails.type === 'plan') {
|
||||
if (effectiveConfirmationDetails.type === 'plan') {
|
||||
content.push({
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: confirmationDetails.plan,
|
||||
text: effectiveConfirmationDetails.plan,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -554,9 +569,9 @@ export class Session implements SessionContext {
|
|||
// Map tool kind, using switch_mode for exit_plan_mode per ACP spec
|
||||
const mappedKind = this.toolCallEmitter.mapToolKind(tool.kind, fc.name);
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
const params: RequestPermissionRequest = {
|
||||
sessionId: this.sessionId,
|
||||
options: toPermissionOptions(confirmationDetails),
|
||||
options: toPermissionOptions(effectiveConfirmationDetails),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
status: 'pending',
|
||||
|
|
@ -564,10 +579,15 @@ export class Session implements SessionContext {
|
|||
content,
|
||||
locations: invocation.toolLocations(),
|
||||
kind: mappedKind,
|
||||
rawInput: args,
|
||||
},
|
||||
};
|
||||
|
||||
const output = await this.client.requestPermission(params);
|
||||
const output = (await this.client.requestPermission(
|
||||
params,
|
||||
)) as RequestPermissionResponse & {
|
||||
answers?: Record<string, string>;
|
||||
};
|
||||
const outcome =
|
||||
output.outcome.outcome === 'cancelled'
|
||||
? ToolConfirmationOutcome.Cancel
|
||||
|
|
@ -575,7 +595,9 @@ export class Session implements SessionContext {
|
|||
.nativeEnum(ToolConfirmationOutcome)
|
||||
.parse(output.outcome.optionId);
|
||||
|
||||
await confirmationDetails.onConfirm(outcome);
|
||||
await effectiveConfirmationDetails.onConfirm(outcome, {
|
||||
answers: output.answers,
|
||||
});
|
||||
|
||||
// After exit_plan_mode confirmation, send current_mode_update notification
|
||||
if (isExitPlanModeTool && outcome !== ToolConfirmationOutcome.Cancel) {
|
||||
|
|
@ -732,7 +754,7 @@ export class Session implements SessionContext {
|
|||
*/
|
||||
async #processSlashCommandResult(
|
||||
result: NonInteractiveSlashCommandResult,
|
||||
originalPrompt: acp.ContentBlock[],
|
||||
originalPrompt: ContentBlock[],
|
||||
): Promise<Part[] | null> {
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
|
|
@ -741,9 +763,7 @@ export class Session implements SessionContext {
|
|||
return normalizePartList(result.content);
|
||||
|
||||
case 'message': {
|
||||
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
|
||||
// by converting it to a stream_messages-like notification
|
||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||
await this.client.extNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command: originalPrompt
|
||||
.filter((block) => block.type === 'text')
|
||||
|
|
@ -770,7 +790,7 @@ export class Session implements SessionContext {
|
|||
|
||||
// Stream all messages to the client
|
||||
for await (const msg of result.messages) {
|
||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||
await this.client.extNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command,
|
||||
messageType: msg.messageType,
|
||||
|
|
@ -812,12 +832,12 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
async #resolvePrompt(
|
||||
message: acp.ContentBlock[],
|
||||
message: ContentBlock[],
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
const FILE_URI_SCHEME = 'file://';
|
||||
|
||||
const embeddedContext: acp.EmbeddedResourceResource[] = [];
|
||||
const embeddedContext: EmbeddedResourceResource[] = [];
|
||||
|
||||
const parts = message.map((part) => {
|
||||
switch (part.type) {
|
||||
|
|
@ -966,7 +986,7 @@ const basicPermissionOptions = [
|
|||
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
): acp.PermissionOption[] {
|
||||
): PermissionOption[] {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
|
|
@ -1027,6 +1047,19 @@ function toPermissionOptions(
|
|||
kind: 'reject_once',
|
||||
},
|
||||
];
|
||||
case 'ask_user_question':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: 'Submit',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: 'Cancel',
|
||||
kind: 'reject_once',
|
||||
},
|
||||
];
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
ToolConfirmationOutcome,
|
||||
TodoWriteTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Helper to create a mock AgentToolCallEvent with required fields
|
||||
|
|
@ -116,7 +116,7 @@ function createStreamTextEvent(
|
|||
|
||||
describe('SubAgentTracker', () => {
|
||||
let mockContext: SessionContext;
|
||||
let mockClient: acp.Client;
|
||||
let mockClient: AgentSideConnection;
|
||||
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
||||
let requestPermissionSpy: ReturnType<typeof vi.fn>;
|
||||
let tracker: SubAgentTracker;
|
||||
|
|
@ -143,7 +143,7 @@ describe('SubAgentTracker', () => {
|
|||
|
||||
mockClient = {
|
||||
requestPermission: requestPermissionSpy,
|
||||
} as unknown as acp.Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
tracker = new SubAgentTracker(
|
||||
mockContext,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ import { z } from 'zod';
|
|||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
PermissionOption,
|
||||
RequestPermissionRequest,
|
||||
ToolCallContent,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
|
||||
const debugLogger = createDebugLogger('ACP_SUBAGENT_TRACKER');
|
||||
|
||||
|
|
@ -80,7 +85,7 @@ export class SubAgentTracker {
|
|||
|
||||
constructor(
|
||||
private readonly ctx: SessionContext,
|
||||
private readonly client: acp.Client,
|
||||
private readonly client: AgentSideConnection,
|
||||
private readonly parentToolCallId: string,
|
||||
private readonly subagentType: string,
|
||||
) {
|
||||
|
|
@ -214,7 +219,7 @@ export class SubAgentTracker {
|
|||
if (abortSignal.aborted) return;
|
||||
|
||||
const state = this.toolStates.get(event.callId);
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
const content: ToolCallContent[] = [];
|
||||
|
||||
// Handle edit confirmation type - show diff
|
||||
if (event.confirmationDetails.type === 'edit') {
|
||||
|
|
@ -243,7 +248,7 @@ export class SubAgentTracker {
|
|||
const { title, locations, kind } =
|
||||
this.toolCallEmitter.resolveToolMetadata(event.name, state?.args);
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
const params: RequestPermissionRequest = {
|
||||
sessionId: this.ctx.sessionId,
|
||||
options: this.toPermissionOptions(fullConfirmationDetails),
|
||||
toolCall: {
|
||||
|
|
@ -324,7 +329,7 @@ export class SubAgentTracker {
|
|||
*/
|
||||
private toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
): acp.PermissionOption[] {
|
||||
): PermissionOption[] {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { SessionContext } from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
import type { SessionUpdate } from '@agentclientprotocol/sdk';
|
||||
|
||||
/**
|
||||
* Abstract base class for all session event emitters.
|
||||
|
|
@ -32,7 +32,7 @@ export abstract class BaseEmitter {
|
|||
/**
|
||||
* Sends a session update to the ACP client.
|
||||
*/
|
||||
protected async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
protected async sendUpdate(update: SessionUpdate): Promise<void> {
|
||||
return this.ctx.sendUpdate(update);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,11 +166,11 @@ describe('MessageEmitter', () => {
|
|||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: 25,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 175,
|
||||
cachedTokens: 10,
|
||||
thoughtTokens: 25,
|
||||
cachedReadTokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -192,11 +192,11 @@ describe('MessageEmitter', () => {
|
|||
content: { type: 'text', text: 'done' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
thoughtsTokens: 2,
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 17,
|
||||
cachedTokens: 1,
|
||||
thoughtTokens: 2,
|
||||
cachedReadTokens: 1,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import type { SubagentMeta } from '../types.js';
|
||||
import type { Usage } from '@agentclientprotocol/sdk';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
|
|
@ -81,11 +81,11 @@ export class MessageEmitter extends BaseEmitter {
|
|||
subagentMeta?: SubagentMeta,
|
||||
): Promise<void> {
|
||||
const usage: Usage = {
|
||||
promptTokens: usageMetadata.promptTokenCount,
|
||||
completionTokens: usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokens: usageMetadata.thoughtsTokenCount,
|
||||
totalTokens: usageMetadata.totalTokenCount,
|
||||
cachedTokens: usageMetadata.cachedContentTokenCount,
|
||||
inputTokens: usageMetadata.promptTokenCount ?? 0,
|
||||
outputTokens: usageMetadata.candidatesTokenCount ?? 0,
|
||||
totalTokens: usageMetadata.totalTokenCount ?? 0,
|
||||
thoughtTokens: usageMetadata.thoughtsTokenCount,
|
||||
cachedReadTokens: usageMetadata.cachedContentTokenCount,
|
||||
};
|
||||
|
||||
const meta =
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
import type { TodoItem } from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
import type { PlanEntry } from '@agentclientprotocol/sdk';
|
||||
|
||||
/**
|
||||
* Handles emission of plan/todo updates.
|
||||
|
|
@ -22,7 +22,7 @@ export class PlanEmitter extends BaseEmitter {
|
|||
* @param todos - Array of todo items to send as plan entries
|
||||
*/
|
||||
async emitPlan(todos: TodoItem[]): Promise<void> {
|
||||
const entries: acp.PlanEntry[] = todos.map((todo) => ({
|
||||
const entries: PlanEntry[] = todos.map((todo) => ({
|
||||
content: todo.content,
|
||||
priority: 'medium' as const, // Default priority since todos don't have priority
|
||||
status: todo.status,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import type {
|
|||
ResolvedToolMetadata,
|
||||
SubagentMeta,
|
||||
} from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
import type {
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { Part } from '@google/genai';
|
||||
import {
|
||||
TodoWriteTool,
|
||||
|
|
@ -103,7 +107,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
}
|
||||
|
||||
// Determine content for the update
|
||||
let contentArray: acp.ToolCallContent[] = [];
|
||||
let contentArray: ToolCallContent[] = [];
|
||||
|
||||
// Special case: diff result from edit tools (format from resultDisplay)
|
||||
const diffContent = this.extractDiffContent(params.resultDisplay);
|
||||
|
|
@ -206,8 +210,8 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
const tool = toolRegistry.getTool(toolName);
|
||||
|
||||
let title = tool?.displayName ?? toolName;
|
||||
let locations: acp.ToolCallLocation[] = [];
|
||||
let kind: acp.ToolKind = 'other';
|
||||
let locations: ToolCallLocation[] = [];
|
||||
let kind: ToolKind = 'other';
|
||||
|
||||
if (tool && args) {
|
||||
try {
|
||||
|
|
@ -234,13 +238,13 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
* @param kind - The core Kind enum value
|
||||
* @param toolName - Optional tool name to handle special cases like exit_plan_mode
|
||||
*/
|
||||
mapToolKind(kind: Kind, toolName?: string): acp.ToolKind {
|
||||
mapToolKind(kind: Kind, toolName?: string): ToolKind {
|
||||
// Special case: exit_plan_mode uses 'switch_mode' kind per ACP spec
|
||||
if (toolName && this.isExitPlanModeTool(toolName)) {
|
||||
return 'switch_mode';
|
||||
}
|
||||
|
||||
const kindMap: Record<Kind, acp.ToolKind> = {
|
||||
const kindMap: Record<Kind, ToolKind> = {
|
||||
[Kind.Read]: 'read',
|
||||
[Kind.Edit]: 'edit',
|
||||
[Kind.Delete]: 'delete',
|
||||
|
|
@ -260,9 +264,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
* Extracts diff content from resultDisplay if it's a diff type (edit tool result).
|
||||
* Returns null if not a diff.
|
||||
*/
|
||||
private extractDiffContent(
|
||||
resultDisplay: unknown,
|
||||
): acp.ToolCallContent | null {
|
||||
private extractDiffContent(resultDisplay: unknown): ToolCallContent | null {
|
||||
if (!resultDisplay || typeof resultDisplay !== 'object') return null;
|
||||
|
||||
const obj = resultDisplay as Record<string, unknown>;
|
||||
|
|
@ -284,10 +286,8 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
* Transforms Part[] to ToolCallContent[].
|
||||
* Extracts text from functionResponse parts and text parts.
|
||||
*/
|
||||
private transformPartsToToolCallContent(
|
||||
parts: Part[],
|
||||
): acp.ToolCallContent[] {
|
||||
const result: acp.ToolCallContent[] = [];
|
||||
private transformPartsToToolCallContent(parts: Part[]): ToolCallContent[] {
|
||||
const result: ToolCallContent[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
// Handle text parts
|
||||
|
|
|
|||
|
|
@ -6,14 +6,20 @@
|
|||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import type * as acp from '../acp.js';
|
||||
import type {
|
||||
SessionUpdate,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
|
||||
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
|
||||
/**
|
||||
* Interface for sending session updates to the ACP client.
|
||||
* Implemented by Session class and used by all emitters.
|
||||
*/
|
||||
export interface SessionUpdateSender {
|
||||
sendUpdate(update: acp.SessionUpdate): Promise<void>;
|
||||
sendUpdate(update: SessionUpdate): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,6 +97,6 @@ export interface TodoItem {
|
|||
*/
|
||||
export interface ResolvedToolMetadata {
|
||||
title: string;
|
||||
locations: acp.ToolCallLocation[];
|
||||
kind: acp.ToolKind;
|
||||
locations: ToolCallLocation[];
|
||||
kind: ToolKind;
|
||||
}
|
||||
|
|
|
|||
25
packages/cli/src/commands/hooks.tsx
Normal file
25
packages/cli/src/commands/hooks.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { enableCommand } from './hooks/enable.js';
|
||||
import { disableCommand } from './hooks/disable.js';
|
||||
|
||||
export const hooksCommand: CommandModule = {
|
||||
command: 'hooks <command>',
|
||||
aliases: ['hook'],
|
||||
describe: 'Manage Qwen Code hooks.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(enableCommand)
|
||||
.command(disableCommand)
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
// This handler is not called when a subcommand is provided.
|
||||
// Yargs will show the help menu.
|
||||
},
|
||||
};
|
||||
75
packages/cli/src/commands/hooks/disable.ts
Normal file
75
packages/cli/src/commands/hooks/disable.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_DISABLE');
|
||||
|
||||
interface DisableArgs {
|
||||
hookName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a hook by adding it to the disabled list
|
||||
*/
|
||||
export async function handleDisableHook(hookName: string): Promise<void> {
|
||||
const workingDir = process.cwd();
|
||||
const settings = loadSettings(workingDir);
|
||||
|
||||
try {
|
||||
// Get current hooks settings
|
||||
const mergedSettings = settings.merged as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const disabledHooks = (hooksSettings['disabled'] || []) as string[];
|
||||
|
||||
// Check if hook is already disabled
|
||||
if (disabledHooks.includes(hookName)) {
|
||||
debugLogger.info(`Hook "${hookName}" is already disabled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add hook to disabled list
|
||||
const newDisabledHooks = [...disabledHooks, hookName];
|
||||
const newHooksSettings = {
|
||||
...hooksSettings,
|
||||
disabled: newDisabledHooks,
|
||||
};
|
||||
|
||||
// Save updated settings
|
||||
settings.setValue(
|
||||
SettingScope.Workspace,
|
||||
'hooks' as keyof typeof settings.merged,
|
||||
newHooksSettings as never,
|
||||
);
|
||||
|
||||
debugLogger.info(`✓ Hook "${hookName}" has been disabled.`);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error disabling hook: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const disableCommand: CommandModule = {
|
||||
command: 'disable <hook-name>',
|
||||
describe: 'Disable an active hook',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('hook-name', {
|
||||
describe: 'Name of the hook to disable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const args = argv as unknown as DisableArgs;
|
||||
await handleDisableHook(args.hookName);
|
||||
process.exit(0);
|
||||
},
|
||||
};
|
||||
75
packages/cli/src/commands/hooks/enable.ts
Normal file
75
packages/cli/src/commands/hooks/enable.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings, SettingScope } from '../../config/settings.js';
|
||||
|
||||
const debugLogger = createDebugLogger('HOOKS_ENABLE');
|
||||
|
||||
interface EnableArgs {
|
||||
hookName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a hook by removing it from the disabled list
|
||||
*/
|
||||
export async function handleEnableHook(hookName: string): Promise<void> {
|
||||
const workingDir = process.cwd();
|
||||
const settings = loadSettings(workingDir);
|
||||
|
||||
try {
|
||||
// Get current hooks settings
|
||||
const mergedSettings = settings.merged as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const disabledHooks = (hooksSettings['disabled'] || []) as string[];
|
||||
|
||||
// Check if hook is in disabled list
|
||||
if (!disabledHooks.includes(hookName)) {
|
||||
debugLogger.info(`Hook "${hookName}" is not disabled.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove hook from disabled list
|
||||
const newDisabledHooks = disabledHooks.filter((h) => h !== hookName);
|
||||
const newHooksSettings = {
|
||||
...hooksSettings,
|
||||
disabled: newDisabledHooks,
|
||||
};
|
||||
|
||||
// Save updated settings
|
||||
settings.setValue(
|
||||
SettingScope.Workspace,
|
||||
'hooks' as keyof typeof settings.merged,
|
||||
newHooksSettings as never,
|
||||
);
|
||||
|
||||
debugLogger.info(`✓ Hook "${hookName}" has been enabled.`);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error enabling hook: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const enableCommand: CommandModule = {
|
||||
command: 'enable <hook-name>',
|
||||
describe: 'Enable a disabled hook',
|
||||
builder: (yargs) =>
|
||||
yargs.positional('hook-name', {
|
||||
describe: 'Name of the hook to enable',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
const args = argv as unknown as EnableArgs;
|
||||
await handleEnableHook(args.hookName);
|
||||
process.exit(0);
|
||||
},
|
||||
};
|
||||
|
|
@ -548,6 +548,43 @@ describe('loadCliConfig', () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should reset context file names to QWEN.md and AGENTS.md by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const setGeminiMdFilenameSpy = vi.spyOn(
|
||||
ServerConfig,
|
||||
'setGeminiMdFilename',
|
||||
);
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith([
|
||||
ServerConfig.DEFAULT_CONTEXT_FILENAME,
|
||||
ServerConfig.AGENT_CONTEXT_FILENAME,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use configured context file name when settings.context.fileName is set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
context: {
|
||||
fileName: 'CUSTOM_AGENTS.md',
|
||||
},
|
||||
};
|
||||
const setGeminiMdFilenameSpy = vi.spyOn(
|
||||
ServerConfig,
|
||||
'setGeminiMdFilename',
|
||||
);
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setGeminiMdFilenameSpy).toHaveBeenCalledWith('CUSTOM_AGENTS.md');
|
||||
});
|
||||
|
||||
it('should propagate stream-json formats to config', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
|
|
@ -567,6 +604,35 @@ describe('loadCliConfig', () => {
|
|||
expect(config.getIncludePartialMessages()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reset context filenames to defaults when context.fileName is not configured', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const defaultContextFiles = ['QWEN.md', 'AGENTS.md'];
|
||||
const getAllSpy = vi
|
||||
.spyOn(ServerConfig, 'getAllGeminiMdFilenames')
|
||||
.mockReturnValue(defaultContextFiles);
|
||||
const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename');
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(getAllSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setFilenameSpy).toHaveBeenCalledWith(defaultContextFiles);
|
||||
});
|
||||
|
||||
it('should use context.fileName from settings when provided', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { context: { fileName: 'CUSTOM_CONTEXT.md' } };
|
||||
const getAllSpy = vi.spyOn(ServerConfig, 'getAllGeminiMdFilenames');
|
||||
const setFilenameSpy = vi.spyOn(ServerConfig, 'setGeminiMdFilename');
|
||||
|
||||
await loadCliConfig(settings, argv);
|
||||
|
||||
expect(setFilenameSpy).toHaveBeenCalledWith('CUSTOM_CONTEXT.md');
|
||||
expect(getAllSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should initialize native LSP service when enabled', async () => {
|
||||
process.argv = ['node', 'script.js', '--experimental-lsp'];
|
||||
const argv = await parseArguments();
|
||||
|
|
@ -1256,7 +1322,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should read excludeMCPServers from settings', async () => {
|
||||
it('should read excludeMCPServers from settings but still return all servers', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -1264,12 +1330,18 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
|||
mcp: { excluded: ['server1', 'server2'] },
|
||||
};
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
// getMcpServers() now returns all servers, use isMcpServerDisabled() to check status
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server1: { url: 'http://localhost:8080' },
|
||||
server2: { url: 'http://localhost:8081' },
|
||||
server3: { url: 'http://localhost:8082' },
|
||||
});
|
||||
expect(config.isMcpServerDisabled('server1')).toBe(true);
|
||||
expect(config.isMcpServerDisabled('server2')).toBe(true);
|
||||
expect(config.isMcpServerDisabled('server3')).toBe(false);
|
||||
});
|
||||
|
||||
it('should override allowMCPServers with excludeMCPServers if overlapping', async () => {
|
||||
it('should apply allowedMcpServers filter but excluded servers are still returned', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -1280,9 +1352,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
|||
},
|
||||
};
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
// allowedMcpServers filters which servers are available
|
||||
// but excluded servers are still returned by getMcpServers()
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server1: { url: 'http://localhost:8080' },
|
||||
server2: { url: 'http://localhost:8081' },
|
||||
});
|
||||
expect(config.isMcpServerDisabled('server1')).toBe(true);
|
||||
expect(config.isMcpServerDisabled('server2')).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize mcp server flag if set', async () => {
|
||||
|
|
@ -2178,8 +2255,8 @@ describe('parseArguments with positional prompt', () => {
|
|||
});
|
||||
|
||||
describe('Telemetry configuration via environment variables', () => {
|
||||
it('should prioritize GEMINI_TELEMETRY_ENABLED over settings', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_ENABLED', 'true');
|
||||
it('should prioritize QWEN_TELEMETRY_ENABLED over settings', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_ENABLED', 'true');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { enabled: false } };
|
||||
|
|
@ -2187,8 +2264,8 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize GEMINI_TELEMETRY_TARGET over settings', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'gcp');
|
||||
it('should prioritize QWEN_TELEMETRY_TARGET over settings', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_TARGET', 'gcp');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -2198,8 +2275,8 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryTarget()).toBe('gcp');
|
||||
});
|
||||
|
||||
it('should throw when GEMINI_TELEMETRY_TARGET is invalid', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_TARGET', 'bogus');
|
||||
it('should throw when QWEN_TELEMETRY_TARGET is invalid', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_TARGET', 'bogus');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -2211,9 +2288,9 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should prioritize GEMINI_TELEMETRY_OTLP_ENDPOINT over settings and default env var', async () => {
|
||||
it('should prioritize QWEN_TELEMETRY_OTLP_ENDPOINT over settings and default env var', async () => {
|
||||
vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://default.env.com');
|
||||
vi.stubEnv('GEMINI_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com');
|
||||
vi.stubEnv('QWEN_TELEMETRY_OTLP_ENDPOINT', 'http://gemini.env.com');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -2223,8 +2300,8 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryOtlpEndpoint()).toBe('http://gemini.env.com');
|
||||
});
|
||||
|
||||
it('should prioritize GEMINI_TELEMETRY_OTLP_PROTOCOL over settings', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_OTLP_PROTOCOL', 'http');
|
||||
it('should prioritize QWEN_TELEMETRY_OTLP_PROTOCOL over settings', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_OTLP_PROTOCOL', 'http');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { otlpProtocol: 'grpc' } };
|
||||
|
|
@ -2232,8 +2309,8 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryOtlpProtocol()).toBe('http');
|
||||
});
|
||||
|
||||
it('should prioritize GEMINI_TELEMETRY_LOG_PROMPTS over settings', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false');
|
||||
it('should prioritize QWEN_TELEMETRY_LOG_PROMPTS over settings', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_LOG_PROMPTS', 'false');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { logPrompts: true } };
|
||||
|
|
@ -2241,8 +2318,8 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize GEMINI_TELEMETRY_OUTFILE over settings', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log');
|
||||
it('should prioritize QWEN_TELEMETRY_OUTFILE over settings', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_OUTFILE', '/gemini/env/telemetry.log');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -2252,8 +2329,8 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryOutfile()).toBe('/gemini/env/telemetry.log');
|
||||
});
|
||||
|
||||
it('should prioritize GEMINI_TELEMETRY_USE_COLLECTOR over settings', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_USE_COLLECTOR', 'true');
|
||||
it('should prioritize QWEN_TELEMETRY_USE_COLLECTOR over settings', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_USE_COLLECTOR', 'true');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { useCollector: false } };
|
||||
|
|
@ -2261,8 +2338,8 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryUseCollector()).toBe(true);
|
||||
});
|
||||
|
||||
it('should use settings value when GEMINI_TELEMETRY_ENABLED is not set', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_ENABLED', undefined);
|
||||
it('should use settings value when QWEN_TELEMETRY_ENABLED is not set', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_ENABLED', undefined);
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { enabled: true } };
|
||||
|
|
@ -2270,8 +2347,8 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should use settings value when GEMINI_TELEMETRY_TARGET is not set', async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_TARGET', undefined);
|
||||
it('should use settings value when QWEN_TELEMETRY_TARGET is not set', async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_TARGET', undefined);
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -2281,16 +2358,16 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryTarget()).toBe('local');
|
||||
});
|
||||
|
||||
it("should treat GEMINI_TELEMETRY_ENABLED='1' as true", async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '1');
|
||||
it("should treat QWEN_TELEMETRY_ENABLED='1' as true", async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_ENABLED', '1');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("should treat GEMINI_TELEMETRY_ENABLED='0' as false", async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_ENABLED', '0');
|
||||
it("should treat QWEN_TELEMETRY_ENABLED='0' as false", async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_ENABLED', '0');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
|
|
@ -2302,16 +2379,16 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='1' as true", async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', '1');
|
||||
it("should treat QWEN_TELEMETRY_LOG_PROMPTS='1' as true", async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_LOG_PROMPTS', '1');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("should treat GEMINI_TELEMETRY_LOG_PROMPTS='false' as false", async () => {
|
||||
vi.stubEnv('GEMINI_TELEMETRY_LOG_PROMPTS', 'false');
|
||||
it("should treat QWEN_TELEMETRY_LOG_PROMPTS='false' as false", async () => {
|
||||
vi.stubEnv('QWEN_TELEMETRY_LOG_PROMPTS', 'false');
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
FileDiscoveryService,
|
||||
FileEncoding,
|
||||
getCurrentGeminiMdFilename,
|
||||
getAllGeminiMdFilenames,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
resolveTelemetrySettings,
|
||||
|
|
@ -33,6 +33,7 @@ import {
|
|||
NativeLspService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
|
|
@ -124,6 +125,7 @@ export interface CliArgs {
|
|||
acp: boolean | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
experimentalLsp: boolean | undefined;
|
||||
experimentalHooks: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
openaiLogging: boolean | undefined;
|
||||
|
|
@ -337,6 +339,12 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-hooks', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Enable experimental hooks feature for lifecycle event customization',
|
||||
default: false,
|
||||
})
|
||||
.option('channel', {
|
||||
type: 'string',
|
||||
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
|
||||
|
|
@ -564,7 +572,9 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
// Register MCP subcommands
|
||||
.command(mcpCommand)
|
||||
// Register Extension subcommands
|
||||
.command(extensionsCommand);
|
||||
.command(extensionsCommand)
|
||||
// Register Hooks subcommands
|
||||
.command(hooksCommand);
|
||||
|
||||
yargsInstance
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
|
|
@ -583,9 +593,11 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
// and not return to main CLI logic
|
||||
if (
|
||||
result._.length > 0 &&
|
||||
(result._[0] === 'mcp' || result._[0] === 'extensions')
|
||||
(result._[0] === 'mcp' ||
|
||||
result._[0] === 'extensions' ||
|
||||
result._[0] === 'hooks')
|
||||
) {
|
||||
// MCP commands handle their own execution and process exit
|
||||
// MCP/Extensions/Hooks commands handle their own execution and process exit
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -691,8 +703,8 @@ export async function loadCliConfig(
|
|||
if (settings.context?.fileName) {
|
||||
setServerGeminiMdFilename(settings.context.fileName);
|
||||
} else {
|
||||
// Reset to default if not provided in settings.
|
||||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
||||
// Reset to default context filenames if not provided in settings.
|
||||
setServerGeminiMdFilename(getAllGeminiMdFilenames());
|
||||
}
|
||||
|
||||
// Automatically load output-language.md if it exists
|
||||
|
|
@ -1014,7 +1026,7 @@ export async function loadCliConfig(
|
|||
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
|
||||
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? true,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
|
|
@ -1024,6 +1036,10 @@ export async function loadCliConfig(
|
|||
output: {
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
hooks: settings.hooks,
|
||||
hooksConfig: settings.hooksConfig,
|
||||
enableHooks:
|
||||
argv.experimentalHooks === true || settings.hooksConfig?.enabled === true,
|
||||
channel: argv.channel,
|
||||
// Precedence: explicit CLI flag > settings file > default(true).
|
||||
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will
|
||||
|
|
|
|||
383
packages/cli/src/config/migration/index.test.ts
Normal file
383
packages/cli/src/config/migration/index.test.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
runMigrations,
|
||||
needsMigration,
|
||||
ALL_MIGRATIONS,
|
||||
MigrationScheduler,
|
||||
} from './index.js';
|
||||
import { SETTINGS_VERSION } from '../settings.js';
|
||||
|
||||
describe('Migration Framework Integration', () => {
|
||||
describe('runMigrations', () => {
|
||||
it('should migrate V1 settings to V3', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
const result = runMigrations(v1Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
});
|
||||
expect(result.executedMigrations[1]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
|
||||
// Check V2 structure was created
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect(settings['ui']).toEqual({
|
||||
theme: 'dark',
|
||||
accessibility: { enableLoadingPhrases: true },
|
||||
});
|
||||
expect(settings['model']).toEqual({ name: 'gemini' });
|
||||
|
||||
// Check disableAutoUpdate was inverted to enableAutoUpdate: false
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should migrate V2 settings to V3', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'light' },
|
||||
general: { disableAutoUpdate: false },
|
||||
};
|
||||
|
||||
const result = runMigrations(v2Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not modify V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
ui: { theme: 'dark' },
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const result = runMigrations(v3Settings, 'user');
|
||||
|
||||
expect(result.finalVersion).toBe(3);
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
expect(result.settings).toEqual(v3Settings);
|
||||
});
|
||||
|
||||
it('should be idempotent', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
const result1 = runMigrations(v1Settings, 'user');
|
||||
const result2 = runMigrations(result1.settings, 'user');
|
||||
|
||||
expect(result1.executedMigrations).toHaveLength(2);
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
expect(result1.finalVersion).toBe(result2.finalVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsMigration', () => {
|
||||
it('should return true for V1 settings', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
expect(needsMigration(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings with deprecated keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(needsMigration(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings without deprecated keys', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
// V2 settings should be migrated to V3 to update the version number
|
||||
expect(needsMigration(cleanV2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for legacy numeric version when no migration can execute', () => {
|
||||
const legacyButUnknownSettings = {
|
||||
$version: 1,
|
||||
customOnlyKey: 'value',
|
||||
};
|
||||
|
||||
expect(needsMigration(legacyButUnknownSettings)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALL_MIGRATIONS', () => {
|
||||
it('should contain all migrations in order', () => {
|
||||
expect(ALL_MIGRATIONS).toHaveLength(2);
|
||||
|
||||
expect(ALL_MIGRATIONS[0].fromVersion).toBe(1);
|
||||
expect(ALL_MIGRATIONS[0].toVersion).toBe(2);
|
||||
|
||||
expect(ALL_MIGRATIONS[1].fromVersion).toBe(2);
|
||||
expect(ALL_MIGRATIONS[1].toVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MigrationScheduler with all migrations', () => {
|
||||
it('should execute full migration chain', () => {
|
||||
const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], 'user');
|
||||
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: true,
|
||||
};
|
||||
|
||||
const result = scheduler.migrate(v1Settings);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
|
||||
const settings = result.settings as Record<string, unknown>;
|
||||
expect(settings['$version']).toBe(3);
|
||||
expect((settings['ui'] as Record<string, unknown>)['theme']).toBe('dark');
|
||||
expect(
|
||||
(settings['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
(settings['ui'] as Record<string, unknown>)[
|
||||
'accessibility'
|
||||
] as Record<string, unknown>
|
||||
)['enableLoadingPhrases'],
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsMigration and runMigrations consistency', () => {
|
||||
it('needsMigration should return true when runMigrations would execute migrations', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
// needsMigration should report that migration is needed
|
||||
expect(needsMigration(v1Settings)).toBe(true);
|
||||
|
||||
// runMigrations should actually execute migrations
|
||||
const result = runMigrations(v1Settings, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('needsMigration should return false when runMigrations would execute no migrations', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should report that no migration is needed
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
|
||||
// runMigrations should execute no migrations
|
||||
const result = runMigrations(v3Settings, 'user');
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle V2 settings without deprecated keys consistently', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
// needsMigration should report that migration is needed
|
||||
expect(needsMigration(cleanV2Settings)).toBe(true);
|
||||
|
||||
// runMigrations should execute the V2->V3 migration
|
||||
const result = runMigrations(cleanV2Settings, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
expect(result.finalVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration chain integrity', () => {
|
||||
it('should have strictly increasing versions (toVersion > fromVersion)', () => {
|
||||
for (const migration of ALL_MIGRATIONS) {
|
||||
expect(migration.toVersion).toBeGreaterThan(migration.fromVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no gaps in the chain (adjacent versions)', () => {
|
||||
for (let i = 1; i < ALL_MIGRATIONS.length; i++) {
|
||||
const prevMigration = ALL_MIGRATIONS[i - 1];
|
||||
const currMigration = ALL_MIGRATIONS[i];
|
||||
expect(currMigration.fromVersion).toBe(prevMigration.toVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no duplicate fromVersions', () => {
|
||||
const fromVersions = ALL_MIGRATIONS.map((m) => m.fromVersion);
|
||||
const uniqueFromVersions = new Set(fromVersions);
|
||||
expect(uniqueFromVersions.size).toBe(fromVersions.length);
|
||||
});
|
||||
|
||||
it('should have no duplicate toVersions', () => {
|
||||
const toVersions = ALL_MIGRATIONS.map((m) => m.toVersion);
|
||||
const uniqueToVersions = new Set(toVersions);
|
||||
expect(uniqueToVersions.size).toBe(toVersions.length);
|
||||
});
|
||||
|
||||
it('should be acyclic (no version appears as fromVersion more than once)', () => {
|
||||
const fromVersionCounts = new Map<number, number>();
|
||||
for (const migration of ALL_MIGRATIONS) {
|
||||
const count = fromVersionCounts.get(migration.fromVersion) || 0;
|
||||
fromVersionCounts.set(migration.fromVersion, count + 1);
|
||||
}
|
||||
|
||||
for (const count of fromVersionCounts.values()) {
|
||||
expect(count).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should chain from version 1 to SETTINGS_VERSION', () => {
|
||||
if (ALL_MIGRATIONS.length > 0) {
|
||||
expect(ALL_MIGRATIONS[0].fromVersion).toBe(1);
|
||||
const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1];
|
||||
expect(lastMigration.toVersion).toBe(SETTINGS_VERSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('single source of truth for version constant', () => {
|
||||
it('should use SETTINGS_VERSION from settings module', () => {
|
||||
// The last migration's toVersion should match SETTINGS_VERSION
|
||||
const lastMigration = ALL_MIGRATIONS[ALL_MIGRATIONS.length - 1];
|
||||
expect(lastMigration.toVersion).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('needsMigration should use SETTINGS_VERSION for version comparison', () => {
|
||||
// Create settings with version equal to SETTINGS_VERSION
|
||||
const currentVersionSettings = {
|
||||
$version: SETTINGS_VERSION,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should return false for current version
|
||||
expect(needsMigration(currentVersionSettings)).toBe(false);
|
||||
|
||||
// Create settings with version less than SETTINGS_VERSION
|
||||
const oldVersionSettings = {
|
||||
$version: SETTINGS_VERSION - 1,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration should return true for old version
|
||||
expect(needsMigration(oldVersionSettings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have SETTINGS_VERSION defined exactly once in codebase', () => {
|
||||
// SETTINGS_VERSION is imported from settings.js
|
||||
// This test verifies the wiring is correct
|
||||
expect(SETTINGS_VERSION).toBeDefined();
|
||||
expect(typeof SETTINGS_VERSION).toBe('number');
|
||||
expect(SETTINGS_VERSION).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid version handling', () => {
|
||||
it('should treat non-numeric version with V1 shape as needing migration', () => {
|
||||
const settingsWithInvalidVersion = {
|
||||
$version: 'invalid',
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
// Should detect migration needed based on V1 shape
|
||||
expect(needsMigration(settingsWithInvalidVersion)).toBe(true);
|
||||
|
||||
// Should run migrations
|
||||
const result = runMigrations(settingsWithInvalidVersion, 'user');
|
||||
expect(result.executedMigrations.length).toBeGreaterThan(0);
|
||||
expect(result.finalVersion).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('should not migrate non-numeric version with already-migrated shape (normalized by loader)', () => {
|
||||
const settingsWithInvalidVersionButMigratedShape = {
|
||||
$version: 'invalid',
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// needsMigration returns false because no migration applies to this shape
|
||||
// The settings loader will handle version normalization separately
|
||||
expect(needsMigration(settingsWithInvalidVersionButMigratedShape)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// No migrations should execute
|
||||
const result = runMigrations(
|
||||
settingsWithInvalidVersionButMigratedShape,
|
||||
'user',
|
||||
);
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should avoid repeated no-op migration loops', () => {
|
||||
// Settings that might cause repeated migrations
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// First check
|
||||
expect(needsMigration(v3Settings)).toBe(false);
|
||||
const result1 = runMigrations(v3Settings, 'user');
|
||||
expect(result1.executedMigrations).toHaveLength(0);
|
||||
|
||||
// Second check should be consistent
|
||||
expect(needsMigration(result1.settings)).toBe(false);
|
||||
const result2 = runMigrations(result1.settings, 'user');
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/cli/src/config/migration/index.ts
Normal file
106
packages/cli/src/config/migration/index.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Export types
|
||||
export type { SettingsMigration, MigrationResult } from './types.js';
|
||||
|
||||
// Export scheduler
|
||||
export { MigrationScheduler } from './scheduler.js';
|
||||
|
||||
// Export migrations
|
||||
export { v1ToV2Migration, V1ToV2Migration } from './versions/v1-to-v2.js';
|
||||
export { v2ToV3Migration, V2ToV3Migration } from './versions/v2-to-v3.js';
|
||||
|
||||
// Import settings version from single source of truth
|
||||
import { SETTINGS_VERSION } from '../settings.js';
|
||||
|
||||
// Ordered array of all migrations for use with MigrationScheduler
|
||||
// Each migration handles one version transition (N → N+1)
|
||||
// Order matters: migrations must be sorted by ascending version
|
||||
import { v1ToV2Migration } from './versions/v1-to-v2.js';
|
||||
import { v2ToV3Migration } from './versions/v2-to-v3.js';
|
||||
import { MigrationScheduler } from './scheduler.js';
|
||||
import type { MigrationResult } from './types.js';
|
||||
|
||||
/**
|
||||
* Ordered array of all settings migrations.
|
||||
* Use this with MigrationScheduler to run the full migration chain.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const scheduler = new MigrationScheduler(ALL_MIGRATIONS);
|
||||
* const result = scheduler.migrate(settings);
|
||||
* ```
|
||||
*/
|
||||
export const ALL_MIGRATIONS = [v1ToV2Migration, v2ToV3Migration] as const;
|
||||
|
||||
/**
|
||||
* Convenience function that runs all migrations on the given settings.
|
||||
* This is the primary entry point for settings migration.
|
||||
*
|
||||
* @param settings - The settings object to migrate
|
||||
* @param scope - The scope of settings being migrated
|
||||
* @returns MigrationResult containing the final settings, version, and execution log
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = runMigrations(settings, 'User');
|
||||
* if (result.executedMigrations.length > 0) {
|
||||
* console.log(`Migrated from version ${result.executedMigrations[0].fromVersion} to ${result.finalVersion}`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function runMigrations(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): MigrationResult {
|
||||
const scheduler = new MigrationScheduler([...ALL_MIGRATIONS], scope);
|
||||
return scheduler.migrate(settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given settings need migration.
|
||||
* Returns true only if at least one registered migration would be applied.
|
||||
*
|
||||
* This function checks:
|
||||
* 1. If $version field exists and is a number:
|
||||
* - Returns false if $version >= SETTINGS_VERSION
|
||||
* - Returns true only when $version < SETTINGS_VERSION AND at least one
|
||||
* migration can execute for the current settings shape
|
||||
* 2. If $version field is missing or invalid:
|
||||
* - Uses fallback logic by checking individual migrations
|
||||
*
|
||||
* Note:
|
||||
* - Legacy numeric versions that have no executable migrations are handled by
|
||||
* the settings loader via version normalization (bump metadata to current).
|
||||
*
|
||||
* @param settings - The settings object to check
|
||||
* @returns true if migration is needed, false otherwise
|
||||
*/
|
||||
export function needsMigration(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
const version = s['$version'];
|
||||
const hasApplicableMigration = ALL_MIGRATIONS.some((migration) =>
|
||||
migration.shouldMigrate(settings),
|
||||
);
|
||||
|
||||
// If $version is a valid number, use version comparison
|
||||
if (typeof version === 'number') {
|
||||
if (version >= SETTINGS_VERSION) {
|
||||
return false;
|
||||
}
|
||||
// Guardrail: only report migration-needed if at least one migration can execute.
|
||||
return hasApplicableMigration;
|
||||
}
|
||||
|
||||
// If $version exists but is not a number (invalid), or is missing:
|
||||
// Use fallback logic - check if any migration would be applied
|
||||
return hasApplicableMigration;
|
||||
}
|
||||
164
packages/cli/src/config/migration/scheduler.test.ts
Normal file
164
packages/cli/src/config/migration/scheduler.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { MigrationScheduler } from './scheduler.js';
|
||||
|
||||
import type { SettingsMigration } from './types.js';
|
||||
|
||||
describe('MigrationScheduler', () => {
|
||||
// Mock migration for testing
|
||||
const createMockMigration = (
|
||||
fromVersion: number,
|
||||
toVersion: number,
|
||||
shouldMigrateResult: boolean,
|
||||
): SettingsMigration => ({
|
||||
fromVersion,
|
||||
toVersion,
|
||||
shouldMigrate: vi.fn().mockReturnValue(shouldMigrateResult),
|
||||
migrate: vi.fn((settings) => ({
|
||||
settings: {
|
||||
...(settings as Record<string, unknown>),
|
||||
$version: toVersion,
|
||||
},
|
||||
warnings: [],
|
||||
})),
|
||||
});
|
||||
|
||||
it('should execute migrations in order when shouldMigrate returns true', () => {
|
||||
const migration1 = createMockMigration(1, 2, true);
|
||||
const migration2 = createMockMigration(2, 3, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
const result = scheduler.migrate({ $version: 1, someKey: 'value' });
|
||||
|
||||
expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration1.migrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.migrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(2);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
});
|
||||
expect(result.executedMigrations[1]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
expect(result.finalVersion).toBe(3);
|
||||
});
|
||||
|
||||
it('should skip migrations when shouldMigrate returns false', () => {
|
||||
const migration1 = createMockMigration(1, 2, false);
|
||||
const migration2 = createMockMigration(2, 3, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
const result = scheduler.migrate({ $version: 2, someKey: 'value' });
|
||||
|
||||
expect(migration1.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration1.migrate).not.toHaveBeenCalled();
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledTimes(1);
|
||||
expect(migration2.migrate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
expect(result.executedMigrations[0]).toEqual({
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be idempotent - running migrations twice produces same result', () => {
|
||||
// Create a migration that checks the version to determine if migration is needed
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn((settings) => {
|
||||
const s = settings as Record<string, unknown>;
|
||||
return s['$version'] !== 2;
|
||||
}),
|
||||
migrate: vi.fn((settings) => ({
|
||||
settings: {
|
||||
...(settings as Record<string, unknown>),
|
||||
$version: 2,
|
||||
},
|
||||
warnings: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
const input = { theme: 'dark' };
|
||||
|
||||
const result1 = scheduler.migrate(input);
|
||||
const result2 = scheduler.migrate(result1.settings);
|
||||
|
||||
expect(result1.executedMigrations).toHaveLength(1);
|
||||
expect(result2.executedMigrations).toHaveLength(0);
|
||||
expect(result1.finalVersion).toBe(result2.finalVersion);
|
||||
});
|
||||
|
||||
it('should pass updated settings to each migration', () => {
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn(() => ({
|
||||
settings: { $version: 2, transformed: true },
|
||||
warnings: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const migration2: SettingsMigration = {
|
||||
fromVersion: 2,
|
||||
toVersion: 3,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn((s) => ({ settings: s, warnings: [] })),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1, migration2], 'user');
|
||||
scheduler.migrate({ $version: 1 });
|
||||
|
||||
expect(migration2.shouldMigrate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ $version: 2, transformed: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty migrations array', () => {
|
||||
const scheduler = new MigrationScheduler([], 'user');
|
||||
const result = scheduler.migrate({ $version: 1, key: 'value' });
|
||||
|
||||
expect(result.executedMigrations).toHaveLength(0);
|
||||
expect(result.finalVersion).toBe(1);
|
||||
expect(result.settings).toEqual({ $version: 1, key: 'value' });
|
||||
});
|
||||
|
||||
it('should throw error when migration fails', () => {
|
||||
const migration1: SettingsMigration = {
|
||||
fromVersion: 1,
|
||||
toVersion: 2,
|
||||
shouldMigrate: vi.fn().mockReturnValue(true),
|
||||
migrate: vi.fn().mockImplementation(() => {
|
||||
throw new Error('Migration failed');
|
||||
}),
|
||||
};
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
|
||||
expect(() => scheduler.migrate({ $version: 1 })).toThrow(
|
||||
'Migration failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle settings without version field', () => {
|
||||
const migration1 = createMockMigration(1, 2, true);
|
||||
|
||||
const scheduler = new MigrationScheduler([migration1], 'user');
|
||||
const result = scheduler.migrate({ theme: 'dark' });
|
||||
|
||||
expect(result.finalVersion).toBe(2);
|
||||
expect(result.executedMigrations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
115
packages/cli/src/config/migration/scheduler.ts
Normal file
115
packages/cli/src/config/migration/scheduler.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import type { SettingsMigration, MigrationResult } from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SETTINGS_MIGRATION');
|
||||
|
||||
/**
|
||||
* Formats a SettingScope enum value to a human-readable string.
|
||||
* - Converts to lowercase
|
||||
* - Special case: 'SystemDefaults' -> 'system default'
|
||||
*/
|
||||
export function formatScope(scope: string): string {
|
||||
if (scope === 'SystemDefaults') {
|
||||
return 'system default';
|
||||
}
|
||||
return scope.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain scheduler for settings migrations.
|
||||
*
|
||||
* The MigrationScheduler orchestrates multiple migrations in sequence,
|
||||
* delegating version detection to each individual migration via `shouldMigrate`.
|
||||
* It has no centralized version logic - migrations self-determine applicability.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Linear chain execution: migrations are applied in registration order
|
||||
* - Idempotent: already-migrated versions return false from shouldMigrate
|
||||
* - Adjacent versions only: each migration handles N → N+1
|
||||
* - Pure functions: migrations don't modify input objects
|
||||
*/
|
||||
export class MigrationScheduler {
|
||||
/**
|
||||
* Creates a new MigrationScheduler with the given migrations.
|
||||
*
|
||||
* @param migrations - Array of migrations in execution order (typically ascending version)
|
||||
* @param scope - The scope of settings being migrated
|
||||
*/
|
||||
constructor(
|
||||
private readonly migrations: SettingsMigration[],
|
||||
private readonly scope: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Executes the migration chain on the given settings.
|
||||
*
|
||||
* Iterates through all registered migrations in order. For each migration:
|
||||
* 1. Calls `shouldMigrate` with the current settings
|
||||
* 2. If true, calls `migrate` to transform the settings
|
||||
* 3. Records the execution
|
||||
*
|
||||
* The scheduler itself has no version awareness - all version detection
|
||||
* is delegated to the individual migrations.
|
||||
*
|
||||
* @param settings - The settings object to migrate
|
||||
* @returns MigrationResult containing the final settings, version, and execution log
|
||||
*/
|
||||
migrate(settings: unknown): MigrationResult {
|
||||
debugLogger.debug('MigrationScheduler: Starting migration chain');
|
||||
|
||||
let current = settings;
|
||||
const executed: Array<{ fromVersion: number; toVersion: number }> = [];
|
||||
const allWarnings: string[] = [];
|
||||
|
||||
for (const migration of this.migrations) {
|
||||
try {
|
||||
if (migration.shouldMigrate(current)) {
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Executing migration ${migration.fromVersion} → ${migration.toVersion}`,
|
||||
);
|
||||
|
||||
const formattedScope = formatScope(this.scope);
|
||||
const result = migration.migrate(current, formattedScope);
|
||||
current = result.settings;
|
||||
allWarnings.push(...result.warnings);
|
||||
|
||||
executed.push({
|
||||
fromVersion: migration.fromVersion,
|
||||
toVersion: migration.toVersion,
|
||||
});
|
||||
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} completed successfully`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`MigrationScheduler: Migration ${migration.fromVersion} → ${migration.toVersion} failed:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final version from the settings object
|
||||
const finalVersion =
|
||||
((current as Record<string, unknown>)['$version'] as number) ?? 1;
|
||||
|
||||
debugLogger.debug(
|
||||
`MigrationScheduler: Migration chain complete. Final version: ${finalVersion}, Executed: ${executed.length} migrations`,
|
||||
);
|
||||
|
||||
return {
|
||||
settings: current,
|
||||
finalVersion,
|
||||
executedMigrations: executed,
|
||||
warnings: allWarnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
58
packages/cli/src/config/migration/types.ts
Normal file
58
packages/cli/src/config/migration/types.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface that all settings migrations must implement.
|
||||
* Each migration handles a single version transition (N → N+1).
|
||||
*/
|
||||
export interface SettingsMigration {
|
||||
/** Source version number */
|
||||
readonly fromVersion: number;
|
||||
|
||||
/** Target version number */
|
||||
readonly toVersion: number;
|
||||
|
||||
/**
|
||||
* Determines whether this migration should be applied to the given settings.
|
||||
* The migration inspects the settings object to detect its current version
|
||||
* and returns true if this migration is applicable.
|
||||
*
|
||||
* @param settings - The current settings object
|
||||
* @returns true if this migration should be applied, false otherwise
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean;
|
||||
|
||||
/**
|
||||
* Executes the migration transformation.
|
||||
* This should be a pure function that does not modify the input object.
|
||||
*
|
||||
* @param settings - The current settings object of version N
|
||||
* @param scope - The scope of settings being migrated
|
||||
* @returns The migrated settings object of version N+1 with optional warnings
|
||||
* @throws Error if the migration fails
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): { settings: unknown; warnings: string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a migration execution by MigrationScheduler.
|
||||
*/
|
||||
export interface MigrationResult {
|
||||
/** The final settings object after all applicable migrations */
|
||||
settings: unknown;
|
||||
|
||||
/** The final version number after migrations */
|
||||
finalVersion: number;
|
||||
|
||||
/** List of migrations that were executed */
|
||||
executedMigrations: Array<{ fromVersion: number; toVersion: number }>;
|
||||
|
||||
/** List of warning messages generated during migration */
|
||||
warnings: string[];
|
||||
}
|
||||
180
packages/cli/src/config/migration/versions/v1-to-v2-shared.ts
Normal file
180
packages/cli/src/config/migration/versions/v1-to-v2-shared.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Structural mapping table for V1 -> V2.
|
||||
*
|
||||
* Used by:
|
||||
* - v1->v2 migration execution
|
||||
* - warnings for residual legacy keys in latest-version settings files
|
||||
*/
|
||||
export const V1_TO_V2_MIGRATION_MAP: Record<string, string> = {
|
||||
accessibility: 'ui.accessibility',
|
||||
allowedTools: 'tools.allowed',
|
||||
allowMCPServers: 'mcp.allowed',
|
||||
autoAccept: 'tools.autoAccept',
|
||||
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
|
||||
bugCommand: 'advanced.bugCommand',
|
||||
chatCompression: 'model.chatCompression',
|
||||
checkpointing: 'general.checkpointing',
|
||||
coreTools: 'tools.core',
|
||||
contextFileName: 'context.fileName',
|
||||
customThemes: 'ui.customThemes',
|
||||
customWittyPhrases: 'ui.customWittyPhrases',
|
||||
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
folderTrust: 'security.folderTrust.enabled',
|
||||
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
showLineNumbers: 'ui.showLineNumbers',
|
||||
showCitations: 'ui.showCitations',
|
||||
ideMode: 'ide.enabled',
|
||||
includeDirectories: 'context.includeDirectories',
|
||||
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
|
||||
maxSessionTurns: 'model.maxSessionTurns',
|
||||
mcpServers: 'mcpServers',
|
||||
mcpServerCommand: 'mcp.serverCommand',
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell',
|
||||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
|
||||
summarizeToolOutput: 'model.summarizeToolOutput',
|
||||
telemetry: 'telemetry',
|
||||
theme: 'ui.theme',
|
||||
toolDiscoveryCommand: 'tools.discoveryCommand',
|
||||
toolCallCommand: 'tools.callCommand',
|
||||
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
|
||||
useExternalAuth: 'security.auth.useExternal',
|
||||
useRipgrep: 'tools.useRipgrep',
|
||||
vimMode: 'general.vimMode',
|
||||
enableWelcomeBack: 'ui.enableWelcomeBack',
|
||||
approvalMode: 'tools.approvalMode',
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level keys that are V2/V3 containers.
|
||||
* If one of these keys already has object value, treat it as latest-format data.
|
||||
*/
|
||||
export const V2_CONTAINER_KEYS = new Set([
|
||||
'ui',
|
||||
'tools',
|
||||
'mcp',
|
||||
'advanced',
|
||||
'model',
|
||||
'general',
|
||||
'context',
|
||||
'security',
|
||||
'ide',
|
||||
'privacy',
|
||||
'telemetry',
|
||||
'extensions',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Legacy disable* keys that remain in disable* form for V2.
|
||||
*/
|
||||
export const V1_TO_V2_PRESERVE_DISABLE_MAP: Record<string, string> = {
|
||||
disableAutoUpdate: 'general.disableAutoUpdate',
|
||||
disableUpdateNag: 'general.disableUpdateNag',
|
||||
disableLoadingPhrases: 'ui.accessibility.disableLoadingPhrases',
|
||||
disableFuzzySearch: 'context.fileFiltering.disableFuzzySearch',
|
||||
disableCacheControl: 'model.generationConfig.disableCacheControl',
|
||||
};
|
||||
|
||||
export const CONSOLIDATED_DISABLE_KEYS = new Set([
|
||||
'disableAutoUpdate',
|
||||
'disableUpdateNag',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Keys that indicate V1-like top-level structure when holding primitive values.
|
||||
*/
|
||||
export const V1_INDICATOR_KEYS = [
|
||||
// From V1_TO_V2_MIGRATION_MAP - keys that map to different paths in V2
|
||||
'theme',
|
||||
'model',
|
||||
'autoAccept',
|
||||
'hideTips',
|
||||
'vimMode',
|
||||
'checkpointing',
|
||||
'accessibility',
|
||||
'allowedTools',
|
||||
'allowMCPServers',
|
||||
'autoConfigureMaxOldSpaceSize',
|
||||
'bugCommand',
|
||||
'chatCompression',
|
||||
'coreTools',
|
||||
'contextFileName',
|
||||
'customThemes',
|
||||
'customWittyPhrases',
|
||||
'debugKeystrokeLogging',
|
||||
'dnsResolutionOrder',
|
||||
'enforcedAuthType',
|
||||
'excludeTools',
|
||||
'excludeMCPServers',
|
||||
'excludedProjectEnvVars',
|
||||
'fileFiltering',
|
||||
'folderTrustFeature',
|
||||
'folderTrust',
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
'hideWindowTitle',
|
||||
'showStatusInTitle',
|
||||
'showLineNumbers',
|
||||
'showCitations',
|
||||
'ideMode',
|
||||
'includeDirectories',
|
||||
'loadMemoryFromIncludeDirectories',
|
||||
'maxSessionTurns',
|
||||
'mcpServerCommand',
|
||||
'memoryImportFormat',
|
||||
'preferredEditor',
|
||||
'sandbox',
|
||||
'selectedAuthType',
|
||||
'shouldUseNodePtyShell',
|
||||
'shellPager',
|
||||
'shellShowColor',
|
||||
'skipNextSpeakerCheck',
|
||||
'summarizeToolOutput',
|
||||
'toolDiscoveryCommand',
|
||||
'toolCallCommand',
|
||||
'usageStatisticsEnabled',
|
||||
'useExternalAuth',
|
||||
'useRipgrep',
|
||||
'enableWelcomeBack',
|
||||
'approvalMode',
|
||||
'sessionTokenLimit',
|
||||
'contentGenerator',
|
||||
'skipLoopDetection',
|
||||
'skipStartupContext',
|
||||
'enableOpenAILogging',
|
||||
'tavilyApiKey',
|
||||
// From V1_TO_V2_PRESERVE_DISABLE_MAP - disable* keys that get nested in V2
|
||||
'disableAutoUpdate',
|
||||
'disableUpdateNag',
|
||||
'disableLoadingPhrases',
|
||||
'disableFuzzySearch',
|
||||
'disableCacheControl',
|
||||
];
|
||||
277
packages/cli/src/config/migration/versions/v1-to-v2.test.ts
Normal file
277
packages/cli/src/config/migration/versions/v1-to-v2.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { V1ToV2Migration } from './v1-to-v2.js';
|
||||
|
||||
describe('V1ToV2Migration', () => {
|
||||
const migration = new V1ToV2Migration();
|
||||
|
||||
describe('shouldMigrate', () => {
|
||||
it('should return true for V1 settings without version and with V1 keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V1 settings with disable* keys', () => {
|
||||
const v1Settings = {
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for settings with $version field', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for settings without V1 indicator keys', () => {
|
||||
const unknownSettings = {
|
||||
customKey: 'value',
|
||||
anotherKey: 123,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(unknownSettings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(migration.shouldMigrate(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-object input', () => {
|
||||
expect(migration.shouldMigrate('string')).toBe(false);
|
||||
expect(migration.shouldMigrate(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate', () => {
|
||||
it('should migrate flat V1 keys to nested V2 structure', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
autoAccept: true,
|
||||
hideTips: false,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toEqual({ theme: 'dark', hideTips: false });
|
||||
expect(result['model']).toEqual({ name: 'gemini' });
|
||||
expect(result['tools']).toEqual({ autoAccept: true });
|
||||
});
|
||||
|
||||
it('should migrate disable* keys to nested V2 paths without inversion', () => {
|
||||
const v1Settings = {
|
||||
theme: 'light',
|
||||
disableAutoUpdate: true,
|
||||
disableLoadingPhrases: false,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['general']).toEqual({ disableAutoUpdate: true });
|
||||
expect(result['ui']).toEqual({
|
||||
theme: 'light',
|
||||
accessibility: { disableLoadingPhrases: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize consolidated disable* non-boolean values to false', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: 'false',
|
||||
disableUpdateNag: null,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['general']).toEqual({
|
||||
disableAutoUpdate: false,
|
||||
disableUpdateNag: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should drop non-boolean non-consolidated disable* values', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableLoadingPhrases: 'TRUE',
|
||||
disableFuzzySearch: 1,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)?.['accessibility'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
(result['context'] as Record<string, unknown>)?.[
|
||||
'fileFiltering'
|
||||
] as Record<string, unknown>
|
||||
)?.['disableFuzzySearch'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve mcpServers at top level', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
mcpServers: {
|
||||
myServer: { command: 'node', args: ['server.js'] },
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['mcpServers']).toEqual({
|
||||
myServer: { command: 'node', args: ['server.js'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve unrecognized keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
myCustomSetting: 'value',
|
||||
anotherCustom: 123,
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['myCustomSetting']).toBe('value');
|
||||
expect(result['anotherCustom']).toBe(123);
|
||||
});
|
||||
|
||||
it('should preserve non-object parent path values on collision', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
ui: 'legacy-ui-string',
|
||||
general: 'legacy-general-string',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toBe('legacy-ui-string');
|
||||
expect(result['general']).toBe('legacy-general-string');
|
||||
});
|
||||
|
||||
it('should not modify the input object', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(v1Settings).toEqual({ theme: 'dark', model: 'gemini' });
|
||||
expect(result).not.toBe(v1Settings);
|
||||
});
|
||||
|
||||
it('should throw error for non-object input', () => {
|
||||
expect(() => migration.migrate(null, 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
expect(() => migration.migrate('string', 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty V1 settings', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
expect(result['ui']).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('should correctly handle all V1 indicator keys', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
model: 'gemini',
|
||||
autoAccept: true,
|
||||
hideTips: false,
|
||||
vimMode: true,
|
||||
checkpointing: false,
|
||||
telemetry: {},
|
||||
accessibility: {},
|
||||
extensions: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v1Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('version properties', () => {
|
||||
it('should have correct fromVersion', () => {
|
||||
expect(migration.fromVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('should have correct toVersion', () => {
|
||||
expect(migration.toVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
267
packages/cli/src/config/migration/versions/v1-to-v2.ts
Normal file
267
packages/cli/src/config/migration/versions/v1-to-v2.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SettingsMigration } from '../types.js';
|
||||
import {
|
||||
CONSOLIDATED_DISABLE_KEYS,
|
||||
V1_INDICATOR_KEYS,
|
||||
V1_TO_V2_MIGRATION_MAP,
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP,
|
||||
V2_CONTAINER_KEYS,
|
||||
} from './v1-to-v2-shared.js';
|
||||
import { setNestedPropertySafe } from '../../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Heuristic indicators for deciding whether an object is "V1-like".
|
||||
*
|
||||
* Detection strategy:
|
||||
* - A file is considered migratable as V1 when:
|
||||
* 1) It is not explicitly versioned as V2+ (`$version` is missing or invalid), and
|
||||
* 2) At least one indicator key appears in a legacy-compatible top-level shape.
|
||||
* - Indicator list intentionally excludes keys that are valid top-level entries in
|
||||
* both old and new structures to reduce false positives.
|
||||
*
|
||||
* Shape rule:
|
||||
* - Object values for indicator keys are treated as already-nested V2-like content
|
||||
* and do not alone trigger migration.
|
||||
* - Primitive/array/null values on indicator keys are treated as legacy V1 signals.
|
||||
*/
|
||||
|
||||
/**
|
||||
* V1 -> V2 migration (structural normalization stage).
|
||||
*
|
||||
* Migration contract:
|
||||
* - Input: settings in legacy V1-like shape (mostly flat, may contain mixed partial V2).
|
||||
* - Output: V2-compatible nested structure with `$version: 2`.
|
||||
* - No semantic inversion of disable* naming in this stage.
|
||||
*
|
||||
* Data-preservation strategy:
|
||||
* - Prefer transforming known keys into canonical V2 locations.
|
||||
* - Preserve unrecognized keys verbatim.
|
||||
* - Preserve parent-path scalar values when nested writes would collide with them.
|
||||
* - Preserve/merge existing partial V2 objects where safe.
|
||||
*
|
||||
* This class intentionally optimizes for backward compatibility and non-destructive
|
||||
* behavior over aggressive normalization.
|
||||
*/
|
||||
export class V1ToV2Migration implements SettingsMigration {
|
||||
readonly fromVersion = 1;
|
||||
readonly toVersion = 2;
|
||||
|
||||
/**
|
||||
* Determines whether this migration should execute.
|
||||
*
|
||||
* Decision strategy:
|
||||
* - Hard-stop when `$version` is a number >= 2 (already V2+).
|
||||
* - Otherwise, scan indicator keys and trigger only when at least one indicator is
|
||||
* still in legacy top-level shape (primitive/array/null).
|
||||
*
|
||||
* Mixed-shape tolerance:
|
||||
* - Files that are partially migrated are supported; V2-like object-valued indicators
|
||||
* are ignored while legacy-shaped indicators can still trigger migration.
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
|
||||
// If $version exists and is a number >= 2, it's not V1
|
||||
const version = s['$version'];
|
||||
if (typeof version === 'number' && version >= 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for V1 indicator keys with primitive values
|
||||
// A setting is considered V1 if ANY indicator key has a primitive value
|
||||
// (string, number, boolean, null, or array) at the top level.
|
||||
// Keys with object values are skipped as they may already be in V2 format.
|
||||
return V1_INDICATOR_KEYS.some((key) => {
|
||||
if (!(key in s)) {
|
||||
return false;
|
||||
}
|
||||
const value = s[key];
|
||||
// Skip keys with object values - they may already be in V2 nested format
|
||||
// But don't let them block migration of other keys
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
// This key appears to be in V2 format, skip it but continue
|
||||
// checking other keys
|
||||
return false;
|
||||
}
|
||||
// Found a key with primitive value - this is V1 format
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs non-destructive V1 -> V2 transformation.
|
||||
*
|
||||
* Detailed strategy:
|
||||
* 1) Relocate known V1 keys using `V1_TO_V2_MIGRATION_MAP`.
|
||||
* - If a source value is already an object and maps to a child path of itself
|
||||
* (partial V2 shape), merge child properties into target path.
|
||||
* 2) Relocate disable* keys into V2 disable* locations.
|
||||
* - Consolidated keys (`disableAutoUpdate`, `disableUpdateNag`): normalize to
|
||||
* boolean with stable-compatible presence semantics (`value === true`).
|
||||
* - Other disable* keys: migrate only boolean values.
|
||||
* 3) Preserve `mcpServers` top-level placement.
|
||||
* 4) Carry over remaining keys:
|
||||
* - If a key is parent of migrated nested paths, merge unprocessed object children.
|
||||
* - If parent value is non-object, preserve that scalar/array/null as-is.
|
||||
* - Otherwise copy untouched key/value.
|
||||
* 5) Stamp `$version = 2`.
|
||||
*
|
||||
* The method is pure with respect to input mutation.
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
_scope: string,
|
||||
): { settings: unknown; warnings: string[] } {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
throw new Error('Settings must be an object');
|
||||
}
|
||||
|
||||
const source = settings as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
const processedKeys = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Step 1: Map known V1 keys to V2 nested paths
|
||||
for (const [v1Key, v2Path] of Object.entries(V1_TO_V2_MIGRATION_MAP)) {
|
||||
if (v1Key in source) {
|
||||
const value = source[v1Key];
|
||||
|
||||
// Safety check: If this key is a V2 container (like 'model') and it's
|
||||
// already an object, it's likely already in V2 format. Skip migration
|
||||
// to prevent double-nesting (e.g., model.name.name).
|
||||
if (
|
||||
V2_CONTAINER_KEYS.has(v1Key) &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
// This is already a V2 container, carry it over as-is
|
||||
result[v1Key] = value;
|
||||
processedKeys.add(v1Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If value is already an object and the path matches the key,
|
||||
// it might be a partial V2 structure. Merge its contents.
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
v2Path.startsWith(v1Key + '.')
|
||||
) {
|
||||
// Merge nested properties from this partial V2 structure
|
||||
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
||||
setNestedPropertySafe(
|
||||
result,
|
||||
`${v2Path}.${nestedKey}`,
|
||||
nestedValue,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setNestedPropertySafe(result, v2Path, value);
|
||||
}
|
||||
processedKeys.add(v1Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Map V1 disable* keys to V2 nested disable* paths
|
||||
for (const [v1Key, v2Path] of Object.entries(
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP,
|
||||
)) {
|
||||
if (v1Key in source) {
|
||||
const value = source[v1Key];
|
||||
if (CONSOLIDATED_DISABLE_KEYS.has(v1Key)) {
|
||||
// Preserve stable behavior: consolidated keys use presence semantics.
|
||||
// Only literal true remains true; all other present values become false.
|
||||
setNestedPropertySafe(result, v2Path, value === true);
|
||||
} else if (typeof value === 'boolean') {
|
||||
// Non-consolidated disable* keys only migrate when explicitly boolean.
|
||||
setNestedPropertySafe(result, v2Path, value);
|
||||
}
|
||||
processedKeys.add(v1Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Preserve mcpServers at the top level
|
||||
if ('mcpServers' in source) {
|
||||
result['mcpServers'] = source['mcpServers'];
|
||||
processedKeys.add('mcpServers');
|
||||
}
|
||||
|
||||
// Step 4: Carry over any unrecognized keys (including unknown nested objects)
|
||||
// Important: Skip keys that are parent paths of already-migrated properties
|
||||
// to avoid overwriting merged structures (e.g., 'ui' should not overwrite 'ui.theme')
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!processedKeys.has(key)) {
|
||||
// Check if this key is a parent of any already-migrated path
|
||||
const isParentOfMigratedPath = Array.from(processedKeys).some(
|
||||
(processedKey) => {
|
||||
// Get the v2 path for this processed key
|
||||
const v2Path =
|
||||
V1_TO_V2_MIGRATION_MAP[processedKey] ||
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey];
|
||||
if (!v2Path) return false;
|
||||
// Check if the v2 path starts with this key + '.'
|
||||
return v2Path.startsWith(key + '.');
|
||||
},
|
||||
);
|
||||
|
||||
if (isParentOfMigratedPath) {
|
||||
// This key is a parent of an already-migrated path
|
||||
// Merge its unprocessed children instead of overwriting
|
||||
const existingValue = source[key];
|
||||
if (
|
||||
typeof existingValue === 'object' &&
|
||||
existingValue !== null &&
|
||||
!Array.isArray(existingValue)
|
||||
) {
|
||||
for (const [nestedKey, nestedValue] of Object.entries(
|
||||
existingValue,
|
||||
)) {
|
||||
// Only merge if this nested key wasn't already processed
|
||||
const fullNestedPath = `${key}.${nestedKey}`;
|
||||
const wasProcessed = Array.from(processedKeys).some(
|
||||
(processedKey) => {
|
||||
const v2Path =
|
||||
V1_TO_V2_MIGRATION_MAP[processedKey] ||
|
||||
V1_TO_V2_PRESERVE_DISABLE_MAP[processedKey];
|
||||
return v2Path === fullNestedPath;
|
||||
},
|
||||
);
|
||||
if (!wasProcessed) {
|
||||
setNestedPropertySafe(result, fullNestedPath, nestedValue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Preserve non-object parent values to match legacy overwrite semantics.
|
||||
result[key] = source[key];
|
||||
}
|
||||
} else {
|
||||
// Not a parent path, safe to copy as-is
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Set version to 2
|
||||
result['$version'] = 2;
|
||||
|
||||
return { settings: result, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance of V1→V2 migration */
|
||||
export const v1ToV2Migration = new V1ToV2Migration();
|
||||
598
packages/cli/src/config/migration/versions/v2-to-v3.test.ts
Normal file
598
packages/cli/src/config/migration/versions/v2-to-v3.test.ts
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { V2ToV3Migration } from './v2-to-v3.js';
|
||||
|
||||
describe('V2ToV3Migration', () => {
|
||||
const migration = new V2ToV3Migration();
|
||||
|
||||
describe('shouldMigrate', () => {
|
||||
it('should return true for V2 settings with deprecated disable* keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings with ui.accessibility.disableLoadingPhrases', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { accessibility: { disableLoadingPhrases: false } },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for V3 settings', () => {
|
||||
const v3Settings = {
|
||||
$version: 3,
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v3Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for V1 settings without version', () => {
|
||||
const v1Settings = {
|
||||
theme: 'dark',
|
||||
disableAutoUpdate: true,
|
||||
};
|
||||
|
||||
expect(migration.shouldMigrate(v1Settings)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for V2 settings without deprecated keys', () => {
|
||||
const cleanV2Settings = {
|
||||
$version: 2,
|
||||
ui: { theme: 'dark' },
|
||||
general: { enableAutoUpdate: true },
|
||||
};
|
||||
|
||||
// V2 settings should always be migrated to V3 to update the version number
|
||||
expect(migration.shouldMigrate(cleanV2Settings)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null input', () => {
|
||||
expect(migration.shouldMigrate(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-object input', () => {
|
||||
expect(migration.shouldMigrate('string')).toBe(false);
|
||||
expect(migration.shouldMigrate(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrate', () => {
|
||||
it('should migrate disableAutoUpdate to enableAutoUpdate with inverted value', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should migrate disableLoadingPhrases to enableLoadingPhrases', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: { accessibility: { disableLoadingPhrases: true } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate disableFuzzySearch to enableFuzzySearch', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
context: { fileFiltering: { disableFuzzySearch: false } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['context'] as Record<string, unknown>)['fileFiltering'],
|
||||
).toEqual({
|
||||
enableFuzzySearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate disableCacheControl to enableCacheControl', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: true } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle consolidated disableAutoUpdate and disableUpdateNag', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
disableUpdateNag: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
// If ANY disable* is true, enable should be false
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set enableAutoUpdate to true when both disable* are false', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: false,
|
||||
disableUpdateNag: false,
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve other settings during migration', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
accessibility: { disableLoadingPhrases: true },
|
||||
},
|
||||
model: {
|
||||
name: 'gemini',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect((result['ui'] as Record<string, unknown>)['theme']).toBe('dark');
|
||||
expect((result['model'] as Record<string, unknown>)['name']).toBe(
|
||||
'gemini',
|
||||
);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify the input object', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: true },
|
||||
};
|
||||
|
||||
const result = migration.migrate(v2Settings, 'user');
|
||||
|
||||
expect(v2Settings.general).toEqual({ disableAutoUpdate: true });
|
||||
expect(result).not.toBe(v2Settings);
|
||||
});
|
||||
|
||||
it('should throw error for non-object input', () => {
|
||||
expect(() => migration.migrate(null, 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
expect(() => migration.migrate('string', 'user')).toThrow(
|
||||
'Settings must be an object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple deprecated keys in one migration', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: false },
|
||||
ui: { accessibility: { disableLoadingPhrases: false } },
|
||||
context: { fileFiltering: { disableFuzzySearch: false } },
|
||||
};
|
||||
|
||||
const { settings: result } = migration.migrate(v2Settings, 'user') as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: unknown[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(
|
||||
(result['ui'] as Record<string, unknown>)['accessibility'],
|
||||
).toEqual({
|
||||
enableLoadingPhrases: true,
|
||||
});
|
||||
expect(
|
||||
(result['context'] as Record<string, unknown>)['fileFiltering'],
|
||||
).toEqual({
|
||||
enableFuzzySearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should coerce string "true" and remove deprecated key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'true' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce string "false" and remove deprecated key', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'false' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(true);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce case-insensitive strings for consolidated keys', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: 'TRUE',
|
||||
disableUpdateNag: 'FALSE',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should remove number value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 123 },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove invalid string value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: 'invalid-string' },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should coerce disableCacheControl string "true"', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 'true' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: false,
|
||||
});
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should coerce disableCacheControl string "false"', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 'false' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({
|
||||
enableCacheControl: true,
|
||||
});
|
||||
expect(warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should remove disableCacheControl number value and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
model: { generationConfig: { disableCacheControl: 456 } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['model'] as Record<string, unknown>)['generationConfig'],
|
||||
).toEqual({});
|
||||
expect(
|
||||
(
|
||||
(result['model'] as Record<string, unknown>)[
|
||||
'generationConfig'
|
||||
] as Record<string, unknown>
|
||||
)['enableCacheControl'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain(
|
||||
'model.generationConfig.disableCacheControl',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed valid and invalid disableAutoUpdate and disableUpdateNag', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
disableUpdateNag: 'invalid',
|
||||
},
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
// Only valid values should contribute to the consolidated result
|
||||
// Since disableAutoUpdate is true, enableAutoUpdate should be false
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBe(false);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableUpdateNag'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableUpdateNag');
|
||||
});
|
||||
|
||||
it('should remove object value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: { nested: 'value' } },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove array value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: [1, 2, 3] },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
|
||||
it('should remove null value for disable key and emit warning', () => {
|
||||
const v2Settings = {
|
||||
$version: 2,
|
||||
general: { disableAutoUpdate: null },
|
||||
};
|
||||
|
||||
const { settings: result, warnings } = migration.migrate(
|
||||
v2Settings,
|
||||
'user',
|
||||
) as {
|
||||
settings: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
expect(result['$version']).toBe(3);
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['disableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(result['general'] as Record<string, unknown>)['enableAutoUpdate'],
|
||||
).toBeUndefined();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('general.disableAutoUpdate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('version properties', () => {
|
||||
it('should have correct fromVersion', () => {
|
||||
expect(migration.fromVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('should have correct toVersion', () => {
|
||||
expect(migration.toVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
222
packages/cli/src/config/migration/versions/v2-to-v3.ts
Normal file
222
packages/cli/src/config/migration/versions/v2-to-v3.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SettingsMigration } from '../types.js';
|
||||
import {
|
||||
deleteNestedPropertySafe,
|
||||
getNestedProperty,
|
||||
setNestedPropertySafe,
|
||||
} from '../../../utils/settingsUtils.js';
|
||||
|
||||
/**
|
||||
* Path mapping for boolean polarity migration (V2 disable* -> V3 enable*).
|
||||
*
|
||||
* Strategy:
|
||||
* - For each mapped path, values are normalized before migration:
|
||||
* - boolean values are accepted directly
|
||||
* - string values "true"/"false" (case-insensitive, trim-aware) are coerced
|
||||
* - all other present values are treated as invalid
|
||||
* - Transformation is inversion-based: disable=true -> enable=false, disable=false -> enable=true.
|
||||
* - Deprecated disable* keys are removed whenever present (valid or invalid).
|
||||
* - Invalid values do not create enable* keys and produce warnings.
|
||||
*/
|
||||
const V2_TO_V3_BOOLEAN_MAP: Record<string, string> = {
|
||||
'general.disableAutoUpdate': 'general.enableAutoUpdate',
|
||||
'general.disableUpdateNag': 'general.enableAutoUpdate',
|
||||
'ui.accessibility.disableLoadingPhrases':
|
||||
'ui.accessibility.enableLoadingPhrases',
|
||||
'context.fileFiltering.disableFuzzySearch':
|
||||
'context.fileFiltering.enableFuzzySearch',
|
||||
'model.generationConfig.disableCacheControl':
|
||||
'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
/**
|
||||
* Consolidated old paths that collapse into one V3 field.
|
||||
*
|
||||
* Current policy:
|
||||
* - `general.disableAutoUpdate` and `general.disableUpdateNag` both drive
|
||||
* `general.enableAutoUpdate`.
|
||||
* - If any valid normalized source is true, target becomes false.
|
||||
* - If at least one valid normalized source exists, consolidated target is emitted.
|
||||
* - Invalid present values are removed and warned, and do not contribute to target calculation.
|
||||
*/
|
||||
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': [
|
||||
'general.disableAutoUpdate',
|
||||
'general.disableUpdateNag',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes deprecated disable* values for migration.
|
||||
*
|
||||
* Returns:
|
||||
* - `isPresent=false` when the path does not exist
|
||||
* - `isPresent=true, isValid=true` when value is boolean or coercible string
|
||||
* - `isPresent=true, isValid=false` for invalid values (number/object/array/null/other strings)
|
||||
*/
|
||||
function normalizeDisableValue(value: unknown): {
|
||||
isPresent: boolean;
|
||||
isValid: boolean;
|
||||
booleanValue?: boolean;
|
||||
} {
|
||||
if (value === undefined) {
|
||||
return { isPresent: false, isValid: false };
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return { isPresent: true, isValid: true, booleanValue: value };
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true') {
|
||||
return { isPresent: true, isValid: true, booleanValue: true };
|
||||
}
|
||||
if (normalized === 'false') {
|
||||
return { isPresent: true, isValid: true, booleanValue: false };
|
||||
}
|
||||
}
|
||||
return { isPresent: true, isValid: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 -> V3 migration (boolean polarity normalization stage).
|
||||
*
|
||||
* Migration contract:
|
||||
* - Input: V2 settings object (`$version: 2`).
|
||||
* - Output: `$version: 3` with deprecated disable* fields removed and
|
||||
* valid values migrated to enable* equivalents.
|
||||
*
|
||||
* Compatibility strategy:
|
||||
* - Accept boolean values and coercible strings "true"/"false".
|
||||
* - Remove invalid deprecated values (rather than preserving them).
|
||||
* - Emit warnings for each removed invalid deprecated key.
|
||||
* - Always bump version to 3 so future loads are idempotent and skip repeated checks.
|
||||
*/
|
||||
export class V2ToV3Migration implements SettingsMigration {
|
||||
readonly fromVersion = 2;
|
||||
readonly toVersion = 3;
|
||||
|
||||
/**
|
||||
* Migration trigger rule.
|
||||
*
|
||||
* Execute only when `$version === 2`.
|
||||
* This includes V2 files with no migratable disable* booleans so that version
|
||||
* metadata still advances to 3.
|
||||
*/
|
||||
shouldMigrate(settings: unknown): boolean {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const s = settings as Record<string, unknown>;
|
||||
|
||||
// Migrate if $version is 2
|
||||
return s['$version'] === 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies V2 -> V3 transformation with deterministic deprecated-key cleanup.
|
||||
*
|
||||
* Detailed strategy:
|
||||
* 1) Clone input.
|
||||
* 2) Process consolidated paths first:
|
||||
* - Inspect each source path.
|
||||
* - Normalize each present value (boolean / coercible string / invalid).
|
||||
* - Always delete present deprecated source key.
|
||||
* - Valid normalized values contribute to aggregate.
|
||||
* - Invalid values emit warnings.
|
||||
* - Emit consolidated target when at least one valid source was consumed.
|
||||
* 3) Process remaining one-to-one mappings:
|
||||
* - For each unmapped source, normalize value.
|
||||
* - If valid -> delete old key and write inverted target.
|
||||
* - If invalid -> delete old key and emit warning.
|
||||
* 4) Set `$version = 3`.
|
||||
*
|
||||
* Guarantees:
|
||||
* - Input object is not mutated.
|
||||
* - Valid migration and invalid cleanup are deterministic.
|
||||
* - Deprecated disable* keys are not retained after migration.
|
||||
*/
|
||||
migrate(
|
||||
settings: unknown,
|
||||
scope: string,
|
||||
): { settings: unknown; warnings: string[] } {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
throw new Error('Settings must be an object');
|
||||
}
|
||||
|
||||
// Deep clone to avoid mutating input
|
||||
const result = structuredClone(settings) as Record<string, unknown>;
|
||||
const processedPaths = new Set<string>();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Step 1: Handle consolidated paths (multiple old paths → single new path)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyBooleanValue = false;
|
||||
|
||||
for (const oldPath of oldPaths) {
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
const normalized = normalizeDisableValue(oldValue);
|
||||
if (!normalized.isPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deleteNestedPropertySafe(result, oldPath);
|
||||
processedPaths.add(oldPath);
|
||||
|
||||
if (normalized.isValid) {
|
||||
hasAnyBooleanValue = true;
|
||||
if (normalized.booleanValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
} else {
|
||||
warnings.push(
|
||||
`Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAnyBooleanValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedPropertySafe(result, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Handle remaining individual disable* → enable* mappings
|
||||
for (const [oldPath, newPath] of Object.entries(V2_TO_V3_BOOLEAN_MAP)) {
|
||||
if (processedPaths.has(oldPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
const normalized = normalizeDisableValue(oldValue);
|
||||
if (!normalized.isPresent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
deleteNestedPropertySafe(result, oldPath);
|
||||
if (normalized.isValid) {
|
||||
// Set new property with inverted value
|
||||
setNestedPropertySafe(result, newPath, !normalized.booleanValue);
|
||||
} else {
|
||||
warnings.push(
|
||||
`Removed deprecated setting '${oldPath}' from ${scope} settings because the value is invalid. Expected boolean or string "true"/"false".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Always update version to 3
|
||||
result['$version'] = 3;
|
||||
|
||||
return { settings: result, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance of V2→V3 migration */
|
||||
export const v2ToV3Migration = new V2ToV3Migration();
|
||||
|
|
@ -38,7 +38,7 @@ function getSandboxCommand(
|
|||
|
||||
// note environment variable takes precedence over argument (from command line or settings)
|
||||
const environmentConfiguredSandbox =
|
||||
process.env['GEMINI_SANDBOX']?.toLowerCase().trim() ?? '';
|
||||
process.env['QWEN_SANDBOX']?.toLowerCase().trim() ?? '';
|
||||
sandbox =
|
||||
environmentConfiguredSandbox?.length > 0
|
||||
? environmentConfiguredSandbox
|
||||
|
|
@ -63,7 +63,7 @@ function getSandboxCommand(
|
|||
return sandbox;
|
||||
}
|
||||
throw new FatalSandboxError(
|
||||
`Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`,
|
||||
`Missing sandbox command '${sandbox}' (from QWEN_SANDBOX)`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -80,8 +80,8 @@ function getSandboxCommand(
|
|||
// throw an error if user requested sandbox but no command was found
|
||||
if (sandbox === true) {
|
||||
throw new FatalSandboxError(
|
||||
'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
|
||||
'install docker or podman or specify command in GEMINI_SANDBOX',
|
||||
'QWEN_SANDBOX is true but failed to determine command for sandbox; ' +
|
||||
'install docker or podman or specify command in QWEN_SANDBOX',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ export async function loadSandboxConfig(
|
|||
const packageJson = await getPackageJson();
|
||||
const image =
|
||||
argv.sandboxImage ??
|
||||
process.env['GEMINI_SANDBOX_IMAGE'] ??
|
||||
process.env['QWEN_SANDBOX_IMAGE'] ??
|
||||
packageJson?.config?.sandboxImageUri;
|
||||
|
||||
return command && image ? { command, image } : undefined;
|
||||
|
|
|
|||
|
|
@ -18,16 +18,6 @@ vi.mock('os', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
// Mock './settings.js' to ensure it uses the mocked 'os.homedir()' for its internal constants.
|
||||
vi.mock('./settings.js', async (importActual) => {
|
||||
const originalModule = await importActual<typeof import('./settings.js')>();
|
||||
return {
|
||||
__esModule: true, // Ensure correct module shape
|
||||
...originalModule, // Re-export all original members
|
||||
// We are relying on originalModule's USER_SETTINGS_PATH being constructed with mocked os.homedir()
|
||||
};
|
||||
});
|
||||
|
||||
// Mock trustedFolders
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi
|
||||
|
|
@ -46,7 +36,6 @@ import {
|
|||
afterEach,
|
||||
type Mocked,
|
||||
type Mock,
|
||||
fail,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs'; // fs will be mocked separately
|
||||
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
|
||||
|
|
@ -60,13 +49,12 @@ import {
|
|||
getSystemSettingsPath,
|
||||
getSystemDefaultsPath,
|
||||
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
|
||||
migrateSettingsToV1,
|
||||
needsMigration,
|
||||
type Settings,
|
||||
loadEnvironment,
|
||||
SETTINGS_VERSION,
|
||||
SETTINGS_VERSION_KEY,
|
||||
} from './settings.js';
|
||||
import { needsMigration } from './migration/index.js';
|
||||
import { FatalConfigError, QWEN_DIR } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const MOCK_WORKSPACE_DIR = '/mock/workspace';
|
||||
|
|
@ -84,6 +72,23 @@ type TestSettings = Settings & {
|
|||
nestedObj?: { [key: string]: unknown };
|
||||
};
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
// Get all the functions from the real 'fs' module
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
|
||||
return {
|
||||
...actualFs, // Keep all the real functions
|
||||
// Now, just override the ones we need for the test
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
realpathSync: (p: string) => p,
|
||||
};
|
||||
});
|
||||
|
||||
// Also mock 'fs' for compatibility
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
// Get all the functions from the real 'fs' module
|
||||
const actualFs = await importOriginal<typeof fs>();
|
||||
|
|
@ -448,7 +453,7 @@ describe('Settings Loading and Merging', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should warn about unknown top-level keys in a v2 settings file', () => {
|
||||
it('should silently ignore unknown top-level keys in a v2 settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
|
|
@ -466,13 +471,7 @@ describe('Settings Loading and Merging', () => {
|
|||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(getSettingsWarnings(settings)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
"Unknown setting 'someUnknownKey' will be ignored",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(getSettingsWarnings(settings)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not warn for valid v2 container keys', () => {
|
||||
|
|
@ -594,19 +593,22 @@ describe('Settings Loading and Merging', () => {
|
|||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify that fs.writeFileSync was called (to add version)
|
||||
// but NOT fs.renameSync (no backup needed, just adding version)
|
||||
expect(fs.renameSync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls[0];
|
||||
const writtenPath = writeCall[0];
|
||||
// Version normalization now uses writeWithBackupSync (temp write + rename)
|
||||
// Verify that writeFileSync was called with the temp file path
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error('Expected temp write call for version normalization');
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
|
||||
expect(writtenPath).toBe(USER_SETTINGS_PATH);
|
||||
expect(writtenContent[SETTINGS_VERSION_KEY]).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.ui?.theme).toBe('dark');
|
||||
expect(writtenContent.model?.name).toBe('qwen-coder');
|
||||
// Verify writeWithBackupSync was called by checking temp file write
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should correctly handle partially migrated settings without version field', () => {
|
||||
|
|
@ -734,14 +736,85 @@ describe('Settings Loading and Merging', () => {
|
|||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Version should be bumped to 3 even though no keys needed migration
|
||||
// writeWithBackupSync writes to a temp file first, then renames
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === USER_SETTINGS_PATH,
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error('Expected temp write call for V2->V3 version bump');
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
});
|
||||
|
||||
it('should normalize invalid version metadata when no migration is applicable', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const invalidVersionSettings = {
|
||||
$version: 'invalid-version',
|
||||
general: {
|
||||
enableAutoUpdate: true,
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(invalidVersionSettings);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error(
|
||||
'Expected temp write call for invalid version normalization',
|
||||
);
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.general?.enableAutoUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('should normalize legacy numeric version when no migration can execute', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const staleVersionSettings = {
|
||||
$version: 1,
|
||||
// No V1/V2 indicators recognized by migrations
|
||||
customOnlyKey: 'value',
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(staleVersionSettings);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
const writeCall = (fs.writeFileSync as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === `${USER_SETTINGS_PATH}.tmp`,
|
||||
);
|
||||
expect(writeCall).toBeDefined();
|
||||
if (!writeCall) {
|
||||
throw new Error(
|
||||
'Expected temp write call for stale version normalization',
|
||||
);
|
||||
}
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
expect(writtenContent.$version).toBe(SETTINGS_VERSION);
|
||||
expect(writtenContent.customOnlyKey).toBe('value');
|
||||
});
|
||||
|
||||
it('should correctly merge and migrate legacy array properties from multiple scopes', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const legacyUserSettings = {
|
||||
|
|
@ -1619,7 +1692,7 @@ describe('Settings Loading and Merging', () => {
|
|||
|
||||
try {
|
||||
loadSettings(MOCK_WORKSPACE_DIR);
|
||||
fail('loadSettings should have thrown a FatalConfigError');
|
||||
throw new Error('loadSettings should have thrown a FatalConfigError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(FatalConfigError);
|
||||
const error = e as FatalConfigError;
|
||||
|
|
@ -2261,385 +2334,6 @@ describe('Settings Loading and Merging', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('migrateSettingsToV1', () => {
|
||||
it('should handle an empty object', () => {
|
||||
const v2Settings = {};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should migrate a simple v2 settings object to v1', () => {
|
||||
const v2Settings = {
|
||||
general: {
|
||||
preferredEditor: 'vscode',
|
||||
vimMode: true,
|
||||
},
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
preferredEditor: 'vscode',
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested properties correctly', () => {
|
||||
const v2Settings = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
auth: {
|
||||
selectedType: 'oauth',
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
autoConfigureMemory: true,
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
folderTrust: true,
|
||||
selectedAuthType: 'oauth',
|
||||
autoConfigureMaxOldSpaceSize: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve mcpServers at the top level', () => {
|
||||
const v2Settings = {
|
||||
general: {
|
||||
preferredEditor: 'vscode',
|
||||
},
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'npm start',
|
||||
},
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
preferredEditor: 'vscode',
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'npm start',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should carry over unrecognized top-level properties', () => {
|
||||
const v2Settings = {
|
||||
general: {
|
||||
vimMode: false,
|
||||
},
|
||||
unrecognized: 'value',
|
||||
another: {
|
||||
nested: true,
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: false,
|
||||
unrecognized: 'value',
|
||||
another: {
|
||||
nested: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a complex object with mixed properties', () => {
|
||||
const v2Settings = {
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
ui: {
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'server-1': {
|
||||
command: 'node server.js',
|
||||
},
|
||||
},
|
||||
unrecognized: {
|
||||
should: 'be-preserved',
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
disableAutoUpdate: true,
|
||||
hideTips: true,
|
||||
customThemes: {
|
||||
myTheme: {},
|
||||
},
|
||||
model: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
mcpServers: {
|
||||
'server-1': {
|
||||
command: 'node server.js',
|
||||
},
|
||||
},
|
||||
unrecognized: {
|
||||
should: 'be-preserved',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not migrate a v1 settings object', () => {
|
||||
const v1Settings = {
|
||||
preferredEditor: 'vscode',
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
};
|
||||
const migratedSettings = migrateSettingsToV1(v1Settings);
|
||||
expect(migratedSettings).toEqual({
|
||||
preferredEditor: 'vscode',
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate a full v2 settings object to v1', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
general: {
|
||||
preferredEditor: 'code',
|
||||
vimMode: true,
|
||||
},
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
privacy: {
|
||||
usageStatisticsEnabled: false,
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
fileName: 'CONTEXT.md',
|
||||
includeDirectories: ['/src'],
|
||||
},
|
||||
tools: {
|
||||
sandbox: true,
|
||||
exclude: ['toolA'],
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['server1'],
|
||||
},
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
dnsResolutionOrder: 'ipv4first',
|
||||
excludedEnvVars: ['SECRET'],
|
||||
},
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'npm start',
|
||||
},
|
||||
},
|
||||
unrecognizedTopLevel: {
|
||||
value: 'should be preserved',
|
||||
},
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
preferredEditor: 'code',
|
||||
vimMode: true,
|
||||
theme: 'dark',
|
||||
usageStatisticsEnabled: false,
|
||||
model: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.8,
|
||||
},
|
||||
contextFileName: 'CONTEXT.md',
|
||||
includeDirectories: ['/src'],
|
||||
sandbox: true,
|
||||
excludeTools: ['toolA'],
|
||||
allowMCPServers: ['server1'],
|
||||
folderTrust: true,
|
||||
dnsResolutionOrder: 'ipv4first',
|
||||
excludedProjectEnvVars: ['SECRET'],
|
||||
mcpServers: {
|
||||
'my-server': {
|
||||
command: 'npm start',
|
||||
},
|
||||
},
|
||||
unrecognizedTopLevel: {
|
||||
value: 'should be preserved',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial v2 settings', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
general: {
|
||||
vimMode: false,
|
||||
},
|
||||
ui: {},
|
||||
model: {
|
||||
name: 'gemini-1.5-pro',
|
||||
},
|
||||
unrecognized: 'value',
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: false,
|
||||
model: 'gemini-1.5-pro',
|
||||
unrecognized: 'value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle settings with different data types', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
general: {
|
||||
vimMode: false,
|
||||
},
|
||||
model: {
|
||||
maxSessionTurns: -1,
|
||||
},
|
||||
context: {
|
||||
includeDirectories: [],
|
||||
},
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: false,
|
||||
maxSessionTurns: -1,
|
||||
includeDirectories: [],
|
||||
folderTrust: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve unrecognized top-level keys', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
general: {
|
||||
vimMode: true,
|
||||
},
|
||||
customTopLevel: {
|
||||
a: 1,
|
||||
b: [2],
|
||||
},
|
||||
anotherOne: 'hello',
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
vimMode: true,
|
||||
customTopLevel: {
|
||||
a: 1,
|
||||
b: [2],
|
||||
},
|
||||
anotherOne: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle an empty v2 settings object', () => {
|
||||
const v2Settings = {};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
expect(v1Settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should correctly handle mcpServers at the top level', () => {
|
||||
const v2Settings: TestSettings = {
|
||||
mcpServers: {
|
||||
serverA: { command: 'a' },
|
||||
},
|
||||
mcp: {
|
||||
allowed: ['serverA'],
|
||||
},
|
||||
};
|
||||
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
expect(v1Settings).toEqual({
|
||||
mcpServers: {
|
||||
serverA: { command: 'a' },
|
||||
},
|
||||
allowMCPServers: ['serverA'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly migrate customWittyPhrases', () => {
|
||||
const v2Settings: Partial<Settings> = {
|
||||
ui: {
|
||||
customWittyPhrases: ['test phrase'],
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings as Settings);
|
||||
expect(v1Settings).toEqual({
|
||||
customWittyPhrases: ['test phrase'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove version field when migrating to V1', () => {
|
||||
const v2Settings = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
},
|
||||
model: {
|
||||
name: 'qwen-coder',
|
||||
},
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
// Version field should not be present in V1 settings
|
||||
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
|
||||
// Other fields should be properly migrated
|
||||
expect(v1Settings).toEqual({
|
||||
theme: 'dark',
|
||||
model: 'qwen-coder',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle version field in unrecognized properties', () => {
|
||||
const v2Settings = {
|
||||
[SETTINGS_VERSION_KEY]: SETTINGS_VERSION,
|
||||
general: {
|
||||
vimMode: true,
|
||||
},
|
||||
someUnrecognizedKey: 'value',
|
||||
};
|
||||
const v1Settings = migrateSettingsToV1(v2Settings);
|
||||
|
||||
// Version field should be filtered out
|
||||
expect(v1Settings[SETTINGS_VERSION_KEY]).toBeUndefined();
|
||||
// Unrecognized keys should be preserved
|
||||
expect(v1Settings['someUnrecognizedKey']).toBe('value');
|
||||
expect(v1Settings['vimMode']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEnvironment', () => {
|
||||
function setup({
|
||||
isFolderTrustEnabled = true,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import {
|
|||
QWEN_DIR,
|
||||
getErrorMessage,
|
||||
Storage,
|
||||
setDebugLogSession,
|
||||
sanitizeCwd,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
|
|
@ -28,9 +31,17 @@ import {
|
|||
getSettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js';
|
||||
import { setNestedPropertySafe } from '../utils/settingsUtils.js';
|
||||
import { customDeepMerge } from '../utils/deepMerge.js';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
import { runMigrations, needsMigration } from './migration/index.js';
|
||||
import {
|
||||
V1_TO_V2_MIGRATION_MAP,
|
||||
V2_CONTAINER_KEYS,
|
||||
} from './migration/versions/v1-to-v2-shared.js';
|
||||
import { writeWithBackupSync } from '../utils/writeWithBackup.js';
|
||||
|
||||
const debugLogger = createDebugLogger('SETTINGS');
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
|
|
@ -54,113 +65,10 @@ export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
|
|||
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
|
||||
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
||||
|
||||
const MIGRATE_V2_OVERWRITE = true;
|
||||
|
||||
// Settings version to track migration state
|
||||
export const SETTINGS_VERSION = 3;
|
||||
export const SETTINGS_VERSION_KEY = '$version';
|
||||
|
||||
const MIGRATION_MAP: Record<string, string> = {
|
||||
accessibility: 'ui.accessibility',
|
||||
allowedTools: 'tools.allowed',
|
||||
allowMCPServers: 'mcp.allowed',
|
||||
autoAccept: 'tools.autoAccept',
|
||||
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
|
||||
bugCommand: 'advanced.bugCommand',
|
||||
chatCompression: 'model.chatCompression',
|
||||
checkpointing: 'general.checkpointing',
|
||||
coreTools: 'tools.core',
|
||||
contextFileName: 'context.fileName',
|
||||
customThemes: 'ui.customThemes',
|
||||
customWittyPhrases: 'ui.customWittyPhrases',
|
||||
debugKeystrokeLogging: 'general.debugKeystrokeLogging',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
folderTrust: 'security.folderTrust.enabled',
|
||||
hasSeenIdeIntegrationNudge: 'ide.hasSeenNudge',
|
||||
hideWindowTitle: 'ui.hideWindowTitle',
|
||||
showStatusInTitle: 'ui.showStatusInTitle',
|
||||
hideTips: 'ui.hideTips',
|
||||
showLineNumbers: 'ui.showLineNumbers',
|
||||
showCitations: 'ui.showCitations',
|
||||
ideMode: 'ide.enabled',
|
||||
includeDirectories: 'context.includeDirectories',
|
||||
loadMemoryFromIncludeDirectories: 'context.loadFromIncludeDirectories',
|
||||
maxSessionTurns: 'model.maxSessionTurns',
|
||||
mcpServers: 'mcpServers',
|
||||
mcpServerCommand: 'mcp.serverCommand',
|
||||
memoryImportFormat: 'context.importFormat',
|
||||
model: 'model.name',
|
||||
preferredEditor: 'general.preferredEditor',
|
||||
sandbox: 'tools.sandbox',
|
||||
selectedAuthType: 'security.auth.selectedType',
|
||||
shouldUseNodePtyShell: 'tools.shell.enableInteractiveShell',
|
||||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
|
||||
summarizeToolOutput: 'model.summarizeToolOutput',
|
||||
telemetry: 'telemetry',
|
||||
theme: 'ui.theme',
|
||||
toolDiscoveryCommand: 'tools.discoveryCommand',
|
||||
toolCallCommand: 'tools.callCommand',
|
||||
usageStatisticsEnabled: 'privacy.usageStatisticsEnabled',
|
||||
useExternalAuth: 'security.auth.useExternal',
|
||||
useRipgrep: 'tools.useRipgrep',
|
||||
vimMode: 'general.vimMode',
|
||||
|
||||
enableWelcomeBack: 'ui.enableWelcomeBack',
|
||||
approvalMode: 'tools.approvalMode',
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
};
|
||||
|
||||
// Settings that need boolean inversion during migration (V1 -> V3)
|
||||
// Old negative naming -> new positive naming with inverted value
|
||||
const INVERTED_BOOLEAN_MIGRATIONS: Record<string, string> = {
|
||||
disableAutoUpdate: 'general.enableAutoUpdate',
|
||||
disableUpdateNag: 'general.enableAutoUpdate',
|
||||
disableLoadingPhrases: 'ui.accessibility.enableLoadingPhrases',
|
||||
disableFuzzySearch: 'context.fileFiltering.enableFuzzySearch',
|
||||
disableCacheControl: 'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
// Consolidated settings: multiple old V1 keys that map to a single new key.
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
|
||||
const CONSOLIDATED_SETTINGS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': ['disableAutoUpdate', 'disableUpdateNag'],
|
||||
};
|
||||
|
||||
// V2 nested paths that need inversion when migrating to V3
|
||||
const INVERTED_V2_PATHS: Record<string, string> = {
|
||||
'general.disableAutoUpdate': 'general.enableAutoUpdate',
|
||||
'general.disableUpdateNag': 'general.enableAutoUpdate',
|
||||
'ui.accessibility.disableLoadingPhrases':
|
||||
'ui.accessibility.enableLoadingPhrases',
|
||||
'context.fileFiltering.disableFuzzySearch':
|
||||
'context.fileFiltering.enableFuzzySearch',
|
||||
'model.generationConfig.disableCacheControl':
|
||||
'model.generationConfig.enableCacheControl',
|
||||
};
|
||||
|
||||
// Consolidated V2 paths: multiple old paths that map to a single new path.
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false.
|
||||
const CONSOLIDATED_V2_PATHS: Record<string, string[]> = {
|
||||
'general.enableAutoUpdate': [
|
||||
'general.disableAutoUpdate',
|
||||
'general.disableUpdateNag',
|
||||
],
|
||||
};
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
|
|
@ -218,312 +126,6 @@ export interface SettingsFile {
|
|||
rawJson?: string;
|
||||
}
|
||||
|
||||
function setNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
value: unknown,
|
||||
) {
|
||||
const keys = path.split('.');
|
||||
const lastKey = keys.pop();
|
||||
if (!lastKey) return;
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (const key of keys) {
|
||||
if (current[key] === undefined) {
|
||||
current[key] = {};
|
||||
}
|
||||
const next = current[key];
|
||||
if (typeof next === 'object' && next !== null) {
|
||||
current = next as Record<string, unknown>;
|
||||
} else {
|
||||
// This path is invalid, so we stop.
|
||||
return;
|
||||
}
|
||||
}
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
// Dynamically determine the top-level keys from the V2 settings structure.
|
||||
const KNOWN_V2_CONTAINERS = new Set([
|
||||
...Object.values(MIGRATION_MAP).map((path) => path.split('.')[0]),
|
||||
...Object.values(INVERTED_BOOLEAN_MIGRATIONS).map(
|
||||
(path) => path.split('.')[0],
|
||||
),
|
||||
]);
|
||||
|
||||
export function needsMigration(settings: Record<string, unknown>): boolean {
|
||||
// Check version field first - if present and matches current version, no migration needed
|
||||
if (SETTINGS_VERSION_KEY in settings) {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version === 'number' && version >= SETTINGS_VERSION) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy detection: A file needs migration if it contains any
|
||||
// top-level key that is moved to a nested location in V2.
|
||||
const hasV1Keys = Object.entries(MIGRATION_MAP).some(([v1Key, v2Path]) => {
|
||||
if (v1Key === v2Path || !(v1Key in settings)) {
|
||||
return false;
|
||||
}
|
||||
// If a key exists that is both a V1 key and a V2 container (like 'model'),
|
||||
// we need to check the type. If it's an object, it's a V2 container and not
|
||||
// a V1 key that needs migration.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(v1Key) &&
|
||||
typeof settings[v1Key] === 'object' &&
|
||||
settings[v1Key] !== null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Also check for old inverted boolean keys (disable* -> enable*)
|
||||
const hasInvertedBooleanKeys = Object.keys(INVERTED_BOOLEAN_MIGRATIONS).some(
|
||||
(v1Key) => v1Key in settings,
|
||||
);
|
||||
|
||||
return hasV1Keys || hasInvertedBooleanKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates V1 (flat) settings directly to V3.
|
||||
* This includes both structural migration (flat -> nested) and boolean
|
||||
* inversion (disable* -> enable*), so migrateV2ToV3 will be skipped.
|
||||
*/
|
||||
function migrateV1ToV3(
|
||||
flatSettings: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
if (!needsMigration(flatSettings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const v2Settings: Record<string, unknown> = {};
|
||||
const flatKeys = new Set(Object.keys(flatSettings));
|
||||
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
if (flatKeys.has(oldKey)) {
|
||||
// Safety check: If this key is a V2 container (like 'model') and it's
|
||||
// already an object, it's likely already in V2 format. Skip migration
|
||||
// to prevent double-nesting (e.g., model.name.name).
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
typeof flatSettings[oldKey] === 'object' &&
|
||||
flatSettings[oldKey] !== null &&
|
||||
!Array.isArray(flatSettings[oldKey])
|
||||
) {
|
||||
// This is already a V2 container, carry it over as-is
|
||||
v2Settings[oldKey] = flatSettings[oldKey];
|
||||
flatKeys.delete(oldKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
setNestedProperty(v2Settings, newPath, flatSettings[oldKey]);
|
||||
flatKeys.delete(oldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle consolidated settings first (multiple old keys -> single new key)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldKeys] of Object.entries(CONSOLIDATED_SETTINGS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyValue = false;
|
||||
for (const oldKey of oldKeys) {
|
||||
if (flatKeys.has(oldKey)) {
|
||||
hasAnyValue = true;
|
||||
const oldValue = flatSettings[oldKey];
|
||||
if (typeof oldValue === 'boolean' && oldValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
flatKeys.delete(oldKey);
|
||||
}
|
||||
}
|
||||
if (hasAnyValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedProperty(v2Settings, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining V1 settings that need boolean inversion (disable* -> enable*)
|
||||
// Skip keys that were already handled by consolidated settings
|
||||
const consolidatedKeys = new Set(Object.values(CONSOLIDATED_SETTINGS).flat());
|
||||
for (const [oldKey, newPath] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
|
||||
if (consolidatedKeys.has(oldKey)) {
|
||||
continue;
|
||||
}
|
||||
if (flatKeys.has(oldKey)) {
|
||||
const oldValue = flatSettings[oldKey];
|
||||
if (typeof oldValue === 'boolean') {
|
||||
setNestedProperty(v2Settings, newPath, !oldValue);
|
||||
}
|
||||
flatKeys.delete(oldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve mcpServers at the top level
|
||||
if (flatSettings['mcpServers']) {
|
||||
v2Settings['mcpServers'] = flatSettings['mcpServers'];
|
||||
flatKeys.delete('mcpServers');
|
||||
}
|
||||
|
||||
// Carry over any unrecognized keys
|
||||
for (const remainingKey of flatKeys) {
|
||||
const existingValue = v2Settings[remainingKey];
|
||||
const newValue = flatSettings[remainingKey];
|
||||
|
||||
if (
|
||||
typeof existingValue === 'object' &&
|
||||
existingValue !== null &&
|
||||
!Array.isArray(existingValue) &&
|
||||
typeof newValue === 'object' &&
|
||||
newValue !== null &&
|
||||
!Array.isArray(newValue)
|
||||
) {
|
||||
const pathAwareGetStrategy = (path: string[]) =>
|
||||
getMergeStrategyForPath([remainingKey, ...path]);
|
||||
v2Settings[remainingKey] = customDeepMerge(
|
||||
pathAwareGetStrategy,
|
||||
{},
|
||||
newValue as MergeableObject,
|
||||
existingValue as MergeableObject,
|
||||
);
|
||||
} else {
|
||||
v2Settings[remainingKey] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Set version field to indicate this is a V2 settings file
|
||||
v2Settings[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
|
||||
return v2Settings;
|
||||
}
|
||||
|
||||
// Migrate V2 settings to V3 (invert disable* -> enable* booleans)
|
||||
function migrateV2ToV3(
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const version = settings[SETTINGS_VERSION_KEY];
|
||||
if (typeof version === 'number' && version >= 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const result = structuredClone(settings);
|
||||
const processedPaths = new Set<string>();
|
||||
|
||||
// Handle consolidated V2 paths first (multiple old paths -> single new path)
|
||||
// Policy: if ANY of the old disable* settings is true, the new enable* should be false
|
||||
for (const [newPath, oldPaths] of Object.entries(CONSOLIDATED_V2_PATHS)) {
|
||||
let hasAnyDisable = false;
|
||||
let hasAnyValue = false;
|
||||
for (const oldPath of oldPaths) {
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
if (typeof oldValue === 'boolean') {
|
||||
hasAnyValue = true;
|
||||
if (oldValue === true) {
|
||||
hasAnyDisable = true;
|
||||
}
|
||||
deleteNestedProperty(result, oldPath);
|
||||
processedPaths.add(oldPath);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (hasAnyValue) {
|
||||
// enableAutoUpdate = !hasAnyDisable (if any disable* was true, enable should be false)
|
||||
setNestedProperty(result, newPath, !hasAnyDisable);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remaining V2 paths that need inversion
|
||||
for (const [oldPath, newPath] of Object.entries(INVERTED_V2_PATHS)) {
|
||||
if (processedPaths.has(oldPath)) {
|
||||
continue;
|
||||
}
|
||||
const oldValue = getNestedProperty(result, oldPath);
|
||||
if (typeof oldValue === 'boolean') {
|
||||
// Remove old property
|
||||
deleteNestedProperty(result, oldPath);
|
||||
// Set new property with inverted value
|
||||
setNestedProperty(result, newPath, !oldValue);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Even if no changes, bump version to 3 to skip future migration checks
|
||||
if (typeof version === 'number' && version < SETTINGS_VERSION) {
|
||||
result[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): void {
|
||||
const keys = path.split('.');
|
||||
const lastKey = keys.pop();
|
||||
if (!lastKey) return;
|
||||
|
||||
let current: Record<string, unknown> = obj;
|
||||
for (const key of keys) {
|
||||
const next = current[key];
|
||||
if (typeof next !== 'object' || next === null) {
|
||||
return;
|
||||
}
|
||||
current = next as Record<string, unknown>;
|
||||
}
|
||||
delete current[lastKey];
|
||||
}
|
||||
|
||||
function getNestedProperty(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): unknown {
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const key of keys) {
|
||||
if (typeof current !== 'object' || current === null || !(key in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
const REVERSE_MIGRATION_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(MIGRATION_MAP).map(([key, value]) => [value, key]),
|
||||
);
|
||||
|
||||
// Reverse map for old V2 paths (before rename) to V1 keys.
|
||||
// Used when migrating settings that still have old V2 naming (e.g., general.disableAutoUpdate).
|
||||
const OLD_V2_TO_V1_MAP: Record<string, string> = {};
|
||||
for (const [oldV2Path, newV3Path] of Object.entries(INVERTED_V2_PATHS)) {
|
||||
// Find the V1 key that maps to this V3 path
|
||||
for (const [v1Key, v3Path] of Object.entries(INVERTED_BOOLEAN_MIGRATIONS)) {
|
||||
if (v3Path === newV3Path) {
|
||||
OLD_V2_TO_V1_MAP[oldV2Path] = v1Key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse map for new V3 paths to V1 keys (with boolean inversion).
|
||||
// Used when migrating settings that have new V3 naming (e.g., general.enableAutoUpdate).
|
||||
const V3_TO_V1_INVERTED_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(INVERTED_BOOLEAN_MIGRATIONS).map(([v1Key, v3Path]) => [
|
||||
v3Path,
|
||||
v1Key,
|
||||
]),
|
||||
);
|
||||
|
||||
function getSettingsFileKeyWarnings(
|
||||
settings: Record<string, unknown>,
|
||||
settingsFilePath: string,
|
||||
|
|
@ -537,7 +139,7 @@ function getSettingsFileKeyWarnings(
|
|||
const ignoredLegacyKeys = new Set<string>();
|
||||
|
||||
// Ignored legacy keys (V1 top-level keys that moved to a nested V2 path).
|
||||
for (const [oldKey, newPath] of Object.entries(MIGRATION_MAP)) {
|
||||
for (const [oldKey, newPath] of Object.entries(V1_TO_V2_MIGRATION_MAP)) {
|
||||
if (oldKey === newPath) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -550,7 +152,7 @@ function getSettingsFileKeyWarnings(
|
|||
// If this key is a V2 container (like 'model') and it's already an object,
|
||||
// it's likely already in V2 format. Don't warn.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(oldKey) &&
|
||||
V2_CONTAINER_KEYS.has(oldKey) &&
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue !== null &&
|
||||
!Array.isArray(oldValue)
|
||||
|
|
@ -564,7 +166,7 @@ function getSettingsFileKeyWarnings(
|
|||
);
|
||||
}
|
||||
|
||||
// Unknown top-level keys.
|
||||
// Unknown top-level keys — log silently to debug output.
|
||||
const schemaKeys = new Set(Object.keys(getSettingsSchema()));
|
||||
for (const key of Object.keys(settings)) {
|
||||
if (key === SETTINGS_VERSION_KEY) {
|
||||
|
|
@ -577,8 +179,8 @@ function getSettingsFileKeyWarnings(
|
|||
continue;
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
`Warning: Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
||||
debugLogger.warn(
|
||||
`Unknown setting '${key}' will be ignored in ${settingsFilePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -586,7 +188,8 @@ function getSettingsFileKeyWarnings(
|
|||
}
|
||||
|
||||
/**
|
||||
* Collects warnings for ignored legacy and unknown settings keys.
|
||||
* Collects warnings for ignored legacy and unknown settings keys,
|
||||
* as well as migration warnings.
|
||||
*
|
||||
* For `$version: 2` settings files, we do not apply implicit migrations.
|
||||
* Instead, we surface actionable, de-duplicated warnings in the terminal UI.
|
||||
|
|
@ -594,6 +197,11 @@ function getSettingsFileKeyWarnings(
|
|||
export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
||||
const warningSet = new Set<string>();
|
||||
|
||||
// Add migration warnings first
|
||||
for (const warning of loadedSettings.migrationWarnings) {
|
||||
warningSet.add(`Warning: ${warning}`);
|
||||
}
|
||||
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
if (settingsFile.rawJson === undefined) {
|
||||
|
|
@ -616,75 +224,6 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
|
|||
return [...warningSet];
|
||||
}
|
||||
|
||||
export function migrateSettingsToV1(
|
||||
v2Settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const v1Settings: Record<string, unknown> = {};
|
||||
const v2Keys = new Set(Object.keys(v2Settings));
|
||||
|
||||
for (const [newPath, oldKey] of Object.entries(REVERSE_MIGRATION_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, newPath);
|
||||
if (value !== undefined) {
|
||||
v1Settings[oldKey] = value;
|
||||
v2Keys.delete(newPath.split('.')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle old V2 inverted paths (no value inversion needed)
|
||||
// e.g., general.disableAutoUpdate -> disableAutoUpdate
|
||||
for (const [oldV2Path, v1Key] of Object.entries(OLD_V2_TO_V1_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, oldV2Path);
|
||||
if (value !== undefined) {
|
||||
v1Settings[v1Key] = value;
|
||||
v2Keys.delete(oldV2Path.split('.')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle new V3 inverted paths (WITH value inversion)
|
||||
// e.g., general.enableAutoUpdate -> disableAutoUpdate (inverted)
|
||||
for (const [v3Path, v1Key] of Object.entries(V3_TO_V1_INVERTED_MAP)) {
|
||||
const value = getNestedProperty(v2Settings, v3Path);
|
||||
if (value !== undefined && typeof value === 'boolean') {
|
||||
v1Settings[v1Key] = !value;
|
||||
v2Keys.delete(v3Path.split('.')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve mcpServers at the top level
|
||||
if (v2Settings['mcpServers']) {
|
||||
v1Settings['mcpServers'] = v2Settings['mcpServers'];
|
||||
v2Keys.delete('mcpServers');
|
||||
}
|
||||
|
||||
// Carry over any unrecognized keys
|
||||
for (const remainingKey of v2Keys) {
|
||||
// Skip the version field - it's only for V2 format
|
||||
if (remainingKey === SETTINGS_VERSION_KEY) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = v2Settings[remainingKey];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't carry over empty objects that were just containers for migrated settings.
|
||||
if (
|
||||
KNOWN_V2_CONTAINERS.has(remainingKey) &&
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
v1Settings[remainingKey] = value;
|
||||
}
|
||||
|
||||
return v1Settings;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
system: Settings,
|
||||
systemDefaults: Settings,
|
||||
|
|
@ -718,6 +257,7 @@ export class LoadedSettings {
|
|||
workspace: SettingsFile,
|
||||
isTrusted: boolean,
|
||||
migratedInMemorScopes: Set<SettingScope>,
|
||||
migrationWarnings: string[] = [],
|
||||
) {
|
||||
this.system = system;
|
||||
this.systemDefaults = systemDefaults;
|
||||
|
|
@ -725,6 +265,7 @@ export class LoadedSettings {
|
|||
this.workspace = workspace;
|
||||
this.isTrusted = isTrusted;
|
||||
this.migratedInMemorScopes = migratedInMemorScopes;
|
||||
this.migrationWarnings = migrationWarnings;
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
|
||||
|
|
@ -734,6 +275,7 @@ export class LoadedSettings {
|
|||
readonly workspace: SettingsFile;
|
||||
readonly isTrusted: boolean;
|
||||
readonly migratedInMemorScopes: Set<SettingScope>;
|
||||
readonly migrationWarnings: string[];
|
||||
|
||||
private _merged: Settings;
|
||||
|
||||
|
|
@ -768,8 +310,8 @@ export class LoadedSettings {
|
|||
|
||||
setValue(scope: SettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
setNestedProperty(settingsFile.settings, key, value);
|
||||
setNestedProperty(settingsFile.originalSettings, key, value);
|
||||
setNestedPropertySafe(settingsFile.settings, key, value);
|
||||
setNestedPropertySafe(settingsFile.originalSettings, key, value);
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
}
|
||||
|
|
@ -793,6 +335,7 @@ export function createMinimalSettings(): LoadedSettings {
|
|||
emptySettingsFile,
|
||||
false,
|
||||
new Set(),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -933,6 +476,16 @@ export function loadEnvironment(settings: Settings): void {
|
|||
export function loadSettings(
|
||||
workspaceDir: string = process.cwd(),
|
||||
): LoadedSettings {
|
||||
// Set up a temporary debug log session for the startup phase.
|
||||
// This allows migration errors to be logged to file instead of being
|
||||
// exposed to users via stderr. The Config class will override this
|
||||
// with the actual session once initialized.
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const sanitizedProjectId = sanitizeCwd(resolvedWorkspaceDir);
|
||||
setDebugLogSession({
|
||||
getSessionId: () => `startup-${sanitizedProjectId}`,
|
||||
});
|
||||
|
||||
let systemSettings: Settings = {};
|
||||
let systemDefaultSettings: Settings = {};
|
||||
let userSettings: Settings = {};
|
||||
|
|
@ -943,7 +496,7 @@ export function loadSettings(
|
|||
const migratedInMemorScopes = new Set<SettingScope>();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
// Note: resolvedWorkspaceDir is already defined at the top of the function
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
|
|
@ -964,7 +517,7 @@ export function loadSettings(
|
|||
const loadAndMigrate = (
|
||||
filePath: string,
|
||||
scope: SettingScope,
|
||||
): { settings: Settings; rawJson?: string } => {
|
||||
): { settings: Settings; rawJson?: string; migrationWarnings?: string[] } => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
|
@ -983,74 +536,59 @@ export function loadSettings(
|
|||
}
|
||||
|
||||
let settingsObject = rawSettings as Record<string, unknown>;
|
||||
const hasVersionKey = SETTINGS_VERSION_KEY in settingsObject;
|
||||
const versionValue = settingsObject[SETTINGS_VERSION_KEY];
|
||||
const hasInvalidVersion =
|
||||
hasVersionKey && typeof versionValue !== 'number';
|
||||
const hasLegacyNumericVersion =
|
||||
typeof versionValue === 'number' && versionValue < SETTINGS_VERSION;
|
||||
let migrationWarnings: string[] | undefined;
|
||||
|
||||
const persistSettingsObject = (warningPrefix: string) => {
|
||||
try {
|
||||
writeWithBackupSync(
|
||||
filePath,
|
||||
JSON.stringify(settingsObject, null, 2),
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(`${warningPrefix}: ${getErrorMessage(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (needsMigration(settingsObject)) {
|
||||
const migratedSettings = migrateV1ToV3(settingsObject);
|
||||
if (migratedSettings) {
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
try {
|
||||
fs.renameSync(filePath, `${filePath}.orig`);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(migratedSettings, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
writeStderrLine(
|
||||
`Error migrating settings file on disk: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
migratedInMemorScopes.add(scope);
|
||||
}
|
||||
settingsObject = migratedSettings;
|
||||
const migrationResult = runMigrations(settingsObject, scope);
|
||||
if (migrationResult.executedMigrations.length > 0) {
|
||||
settingsObject = migrationResult.settings as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
migrationWarnings = migrationResult.warnings;
|
||||
persistSettingsObject('Error migrating settings file on disk');
|
||||
} else if (hasLegacyNumericVersion || hasInvalidVersion) {
|
||||
// Migration was deemed needed but nothing executed. Normalize version metadata
|
||||
// to avoid repeated no-op checks on startup.
|
||||
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
debugLogger.warn(
|
||||
`Settings version metadata in ${filePath} could not be migrated by any registered migration. Normalizing ${SETTINGS_VERSION_KEY} to ${SETTINGS_VERSION}.`,
|
||||
);
|
||||
persistSettingsObject('Error normalizing settings version on disk');
|
||||
}
|
||||
} else if (!(SETTINGS_VERSION_KEY in settingsObject)) {
|
||||
// No migration needed, but version field is missing - add it for future optimizations
|
||||
} else if (
|
||||
!hasVersionKey ||
|
||||
hasInvalidVersion ||
|
||||
hasLegacyNumericVersion
|
||||
) {
|
||||
// No migration needed/executable, but version metadata is missing or invalid.
|
||||
// Normalize it to current version to avoid repeated startup work.
|
||||
settingsObject[SETTINGS_VERSION_KEY] = SETTINGS_VERSION;
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(settingsObject, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
writeStderrLine(
|
||||
`Error adding version to settings file: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
persistSettingsObject('Error normalizing settings version on disk');
|
||||
}
|
||||
|
||||
// V2 to V3 migration (invert disable* -> enable* booleans)
|
||||
const v3Migrated = migrateV2ToV3(settingsObject);
|
||||
if (v3Migrated) {
|
||||
if (MIGRATE_V2_OVERWRITE) {
|
||||
try {
|
||||
// Only backup if not already backed up by V1->V2 migration
|
||||
const backupPath = `${filePath}.orig`;
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
fs.renameSync(filePath, backupPath);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(v3Migrated, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
writeStderrLine(
|
||||
`Error migrating settings file to V3: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
migratedInMemorScopes.add(scope);
|
||||
}
|
||||
settingsObject = v3Migrated;
|
||||
}
|
||||
|
||||
return { settings: settingsObject as Settings, rawJson: content };
|
||||
return {
|
||||
settings: settingsObject as Settings,
|
||||
rawJson: content,
|
||||
migrationWarnings,
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
|
|
@ -1068,7 +606,11 @@ export function loadSettings(
|
|||
);
|
||||
const userResult = loadAndMigrate(USER_SETTINGS_PATH, SettingScope.User);
|
||||
|
||||
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
||||
let workspaceResult: {
|
||||
settings: Settings;
|
||||
rawJson?: string;
|
||||
migrationWarnings?: string[];
|
||||
} = {
|
||||
settings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
|
|
@ -1138,6 +680,14 @@ export function loadSettings(
|
|||
);
|
||||
}
|
||||
|
||||
// Collect all migration warnings from all scopes
|
||||
const allMigrationWarnings: string[] = [
|
||||
...(systemResult.migrationWarnings ?? []),
|
||||
...(systemDefaultsResult.migrationWarnings ?? []),
|
||||
...(userResult.migrationWarnings ?? []),
|
||||
...(workspaceResult.migrationWarnings ?? []),
|
||||
];
|
||||
|
||||
return new LoadedSettings(
|
||||
{
|
||||
path: systemSettingsPath,
|
||||
|
|
@ -1165,6 +715,7 @@ export function loadSettings(
|
|||
},
|
||||
isTrusted,
|
||||
migratedInMemorScopes,
|
||||
allMigrationWarnings,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1176,21 +727,14 @@ export function saveSettings(settingsFile: SettingsFile): void {
|
|||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
let settingsToSave = settingsFile.originalSettings;
|
||||
if (!MIGRATE_V2_OVERWRITE) {
|
||||
settingsToSave = migrateSettingsToV1(
|
||||
settingsToSave as Record<string, unknown>,
|
||||
) as Settings;
|
||||
}
|
||||
|
||||
// Use the format-preserving update function
|
||||
updateSettingsFilePreservingFormat(
|
||||
settingsFile.path,
|
||||
settingsToSave as Record<string, unknown>,
|
||||
settingsFile.originalSettings as Record<string, unknown>,
|
||||
);
|
||||
} catch (error) {
|
||||
writeStderrLine('Error saving user settings file.');
|
||||
writeStderrLine(error instanceof Error ? error.message : String(error));
|
||||
debugLogger.error('Error saving user settings file.');
|
||||
debugLogger.error(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -589,7 +589,7 @@ const SETTINGS_SCHEMA = {
|
|||
label: 'Skip Loop Detection',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: false,
|
||||
},
|
||||
|
|
@ -822,9 +822,9 @@ const SETTINGS_SCHEMA = {
|
|||
label: 'Interactive Shell (PTY)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description:
|
||||
'Use node-pty for an interactive shell experience. Fallback to child_process still applies.',
|
||||
'Use node-pty for an interactive shell experience. Falls back to child_process if PTY is unavailable.',
|
||||
showInDialog: true,
|
||||
},
|
||||
pager: {
|
||||
|
|
@ -1275,6 +1275,75 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
|
||||
hooksConfig: {
|
||||
type: 'object',
|
||||
label: 'Hooks Config',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description:
|
||||
'Hook configurations for intercepting and customizing agent behavior.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
},
|
||||
disabled: {
|
||||
type: 'array',
|
||||
label: 'Disabled Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'List of hook names (commands) that should be disabled. Hooks in this list will not execute even if configured.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
type: 'object',
|
||||
label: 'Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description:
|
||||
'Hook event configurations for extending CLI behavior at various lifecycle points.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
UserPromptSubmit: {
|
||||
type: 'array',
|
||||
label: 'Before Agent Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute before agent processing. Can modify prompts or inject context.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
Stop: {
|
||||
type: 'array',
|
||||
label: 'After Agent Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute after agent processing. Can post-process responses or log interactions.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
experimental: {
|
||||
type: 'object',
|
||||
label: 'Experimental',
|
||||
|
|
|
|||
|
|
@ -158,9 +158,9 @@ describe('Trusted Folders Loading', () => {
|
|||
expect(errors[0].message).toContain('Unexpected token');
|
||||
});
|
||||
|
||||
it('should use GEMINI_CLI_TRUSTED_FOLDERS_PATH env var if set', () => {
|
||||
it('should use QWEN_CODE_TRUSTED_FOLDERS_PATH env var if set', () => {
|
||||
const customPath = '/custom/path/to/trusted_folders.json';
|
||||
process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath;
|
||||
process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'] = customPath;
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation((p) => p === customPath);
|
||||
const userContent = {
|
||||
|
|
@ -180,7 +180,7 @@ describe('Trusted Folders Loading', () => {
|
|||
]);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
delete process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
|
||||
delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'];
|
||||
});
|
||||
|
||||
it('setValue should update the user config and save it', () => {
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ export const SETTINGS_DIRECTORY_NAME = '.qwen';
|
|||
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||
|
||||
export function getTrustedFoldersPath(): string {
|
||||
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
|
||||
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
|
||||
if (process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']) {
|
||||
return process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'];
|
||||
}
|
||||
return path.join(USER_SETTINGS_DIR, TRUSTED_FOLDERS_FILENAME);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,42 @@ export function generateCodingPlanTemplate(
|
|||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[Bailian Coding Plan] glm-5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[Bailian Coding Plan] kimi-k2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[Bailian Coding Plan] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: '[Bailian Coding Plan] qwen3-coder-plus',
|
||||
|
|
@ -106,42 +142,6 @@ export function generateCodingPlanTemplate(
|
|||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'glm-5',
|
||||
name: '[Bailian Coding Plan] glm-5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 202752,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.5',
|
||||
name: '[Bailian Coding Plan] MiniMax-M2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.5',
|
||||
name: '[Bailian Coding Plan] kimi-k2.5',
|
||||
baseUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
envKey: CODING_PLAN_ENV_KEY,
|
||||
generationConfig: {
|
||||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 262144,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,9 +113,9 @@ describe('gemini.tsx main function', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
// Store and clear sandbox-related env variables to ensure a consistent test environment
|
||||
originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX'];
|
||||
originalEnvGeminiSandbox = process.env['QWEN_SANDBOX'];
|
||||
originalEnvSandbox = process.env['SANDBOX'];
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
delete process.env['QWEN_SANDBOX'];
|
||||
delete process.env['SANDBOX'];
|
||||
|
||||
initialUnhandledRejectionListeners =
|
||||
|
|
@ -125,9 +125,9 @@ describe('gemini.tsx main function', () => {
|
|||
afterEach(() => {
|
||||
// Restore original env variables
|
||||
if (originalEnvGeminiSandbox !== undefined) {
|
||||
process.env['GEMINI_SANDBOX'] = originalEnvGeminiSandbox;
|
||||
process.env['QWEN_SANDBOX'] = originalEnvGeminiSandbox;
|
||||
} else {
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
delete process.env['QWEN_SANDBOX'];
|
||||
}
|
||||
if (originalEnvSandbox !== undefined) {
|
||||
process.env['SANDBOX'] = originalEnvSandbox;
|
||||
|
|
@ -190,6 +190,7 @@ describe('gemini.tsx main function', () => {
|
|||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
} as never);
|
||||
try {
|
||||
await main();
|
||||
|
|
@ -262,7 +263,7 @@ describe('gemini.tsx main function', () => {
|
|||
'isRaw',
|
||||
);
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
value: false, // 在 stream-json 模式下应为 false
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'isRaw', {
|
||||
|
|
@ -322,6 +323,7 @@ describe('gemini.tsx main function', () => {
|
|||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
} as never);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
|
|
@ -344,6 +346,9 @@ describe('gemini.tsx main function', () => {
|
|||
getInputFormat: () => 'stream-json',
|
||||
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
|
||||
getWarnings: () => [],
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getOutputFormat: () => OutputFormat.TEXT,
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
|
||||
|
|
@ -442,6 +447,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getWarnings: () => [],
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
errors: [],
|
||||
|
|
@ -452,6 +458,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
migrationWarnings: [],
|
||||
} as never);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
model: undefined,
|
||||
|
|
@ -497,6 +504,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
authType: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
experimentalLsp: undefined,
|
||||
experimentalHooks: undefined,
|
||||
channel: undefined,
|
||||
chatRecording: undefined,
|
||||
sessionId: undefined,
|
||||
|
|
|
|||
|
|
@ -388,17 +388,16 @@ export async function main() {
|
|||
setMaxSizedBoxDebugging(isDebugMode);
|
||||
|
||||
// Check input format early to determine initialization flow
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
// In TTY mode, ignore stream-json input format to prevent process from hanging
|
||||
const inputFormat = process.stdin.isTTY
|
||||
? InputFormat.TEXT
|
||||
: typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
|
||||
// For stream-json mode, defer config.initialize() until after the initialize control request
|
||||
// For other modes, initialize normally
|
||||
let initializationResult: InitializationResult | undefined;
|
||||
if (inputFormat !== InputFormat.STREAM_JSON) {
|
||||
initializationResult = await initializeApp(config, settings);
|
||||
}
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
|
||||
if (config.getExperimentalZedIntegration()) {
|
||||
return runAcpAgent(config, settings, argv);
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:',
|
||||
'No tools available': 'Keine Werkzeuge verfügbar',
|
||||
|
|
@ -360,7 +360,9 @@ export default {
|
|||
'Show tool-specific usage statistics.':
|
||||
'Werkzeugspezifische Nutzungsstatistiken anzeigen.',
|
||||
'exit the cli': 'CLI beenden',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'MCP-Verwaltungsdialog öffnen oder mit OAuth-fähigem Server authentifizieren',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fähigen Servern authentifizieren',
|
||||
'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
|
|
@ -882,9 +884,101 @@ export default {
|
|||
'Do you want to proceed?': 'Möchten Sie fortfahren?',
|
||||
'Yes, allow once': 'Ja, einmal erlauben',
|
||||
'Allow always': 'Immer erlauben',
|
||||
Yes: 'Ja',
|
||||
No: 'Nein',
|
||||
'No (esc)': 'Nein (Esc)',
|
||||
'Yes, allow always for this session': 'Ja, für diese Sitzung immer erlauben',
|
||||
|
||||
// MCP Management Dialog (translations for MCP UI components)
|
||||
'Manage MCP servers': 'MCP-Server verwalten',
|
||||
'Server Detail': 'Serverdetails',
|
||||
'Disable Server': 'Server deaktivieren',
|
||||
Tools: 'Werkzeuge',
|
||||
'Tool Detail': 'Werkzeugdetails',
|
||||
'MCP Management': 'MCP-Verwaltung',
|
||||
'Loading...': 'Lädt...',
|
||||
'Unknown step': 'Unbekannter Schritt',
|
||||
'Esc to back': 'Esc zurück',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ navigieren · Enter auswählen · Esc schließen',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ navigieren · Enter auswählen · Esc zurück',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ navigieren · Enter bestätigen · Esc zurück',
|
||||
'User Settings (global)': 'Benutzereinstellungen (global)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'Arbeitsbereichseinstellungen (projektspezifisch)',
|
||||
'Disable server:': 'Server deaktivieren:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'Wählen Sie, wo der Server zur Ausschlussliste hinzugefügt werden soll:',
|
||||
'Press Enter to confirm, Esc to cancel':
|
||||
'Enter zum Bestätigen, Esc zum Abbrechen',
|
||||
Disable: 'Deaktivieren',
|
||||
Enable: 'Aktivieren',
|
||||
Reconnect: 'Neu verbinden',
|
||||
'View tools': 'Werkzeuge anzeigen',
|
||||
'Status:': 'Status:',
|
||||
'Command:': 'Befehl:',
|
||||
'Working Directory:': 'Arbeitsverzeichnis:',
|
||||
'Capabilities:': 'Fähigkeiten:',
|
||||
'No server selected': 'Kein Server ausgewählt',
|
||||
'(disabled)': '(deaktiviert)',
|
||||
'Error:': 'Fehler:',
|
||||
Extension: 'Erweiterung',
|
||||
tool: 'Werkzeug',
|
||||
tools: 'Werkzeuge',
|
||||
connected: 'verbunden',
|
||||
connecting: 'verbindet',
|
||||
disconnected: 'getrennt',
|
||||
error: 'Fehler',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'Benutzer-MCPs',
|
||||
'Project MCPs': 'Projekt-MCPs',
|
||||
'Extension MCPs': 'Erweiterungs-MCPs',
|
||||
server: 'Server',
|
||||
servers: 'Server',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'Fügen Sie MCP-Server zu Ihren Einstellungen hinzu, um zu beginnen.',
|
||||
'Run qwen --debug to see error logs':
|
||||
'Führen Sie qwen --debug aus, um Fehlerprotokolle anzuzeigen',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.':
|
||||
'Keine Werkzeuge für diesen Server verfügbar.',
|
||||
destructive: 'destruktiv',
|
||||
'read-only': 'schreibgeschützt',
|
||||
'open-world': 'offene Welt',
|
||||
idempotent: 'idempotent',
|
||||
'Tools for {{name}}': 'Werkzeuge für {{name}}',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: 'erforderlich',
|
||||
Type: 'Typ',
|
||||
Enum: 'Aufzählung',
|
||||
Parameters: 'Parameter',
|
||||
'No tool selected': 'Kein Werkzeug ausgewählt',
|
||||
Annotations: 'Anmerkungen',
|
||||
Title: 'Titel',
|
||||
'Read Only': 'Schreibgeschützt',
|
||||
Destructive: 'Destruktiv',
|
||||
Idempotent: 'Idempotent',
|
||||
'Open World': 'Offene Welt',
|
||||
Server: 'Server',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} ungültige Werkzeuge',
|
||||
invalid: 'ungültig',
|
||||
'invalid: {{reason}}': 'ungültig: {{reason}}',
|
||||
'missing name': 'Name fehlt',
|
||||
'missing description': 'Beschreibung fehlt',
|
||||
'(unnamed)': '(unbenannt)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'Warnung: Dieses Werkzeug kann nicht vom LLM aufgerufen werden',
|
||||
Reason: 'Grund',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'Werkzeuge müssen sowohl einen Namen als auch eine Beschreibung haben, um vom LLM verwendet zu werden.',
|
||||
'Modify in progress:': 'Änderung in Bearbeitung:',
|
||||
'Save and close external editor to continue':
|
||||
'Speichern und externen Editor schließen, um fortzufahren',
|
||||
|
|
@ -1445,6 +1539,18 @@ export default {
|
|||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Coding Plan': 'Coding Plan',
|
||||
"Paste your api key of Bailian Coding Plan and you're all set!":
|
||||
'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!',
|
||||
Custom: 'Benutzerdefiniert',
|
||||
'More instructions about configuring `modelProviders` manually.':
|
||||
'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.',
|
||||
'Select API-KEY configuration mode:':
|
||||
'API-KEY-Konfigurationsmodus auswählen:',
|
||||
'(Press Escape to go back)': '(Escape drücken zum Zurückgehen)',
|
||||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Enter zum Absenden, Escape zum Abbrechen)',
|
||||
'More instructions please check:': 'Weitere Anweisungen finden Sie unter:',
|
||||
'Select Region for Coding Plan': 'Region für Coding Plan auswählen',
|
||||
'Choose based on where your account is registered':
|
||||
'Wählen Sie basierend auf dem Registrierungsort Ihres Kontos',
|
||||
|
|
@ -1457,6 +1563,39 @@ export default {
|
|||
'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'{{region}}-Konfiguration erfolgreich aktualisiert.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert.',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'Tipp: Verwenden Sie /model, um zwischen verfügbaren Coding Plan-Modellen zu wechseln.',
|
||||
|
||||
// ============================================================================
|
||||
// Ask User Question Tool
|
||||
// ============================================================================
|
||||
'Please answer the following question(s):':
|
||||
'Bitte beantworten Sie die folgende(n) Frage(n):',
|
||||
'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.':
|
||||
'Benutzerfragen können im nicht-interaktiven Modus nicht gestellt werden. Bitte führen Sie das Tool im interaktiven Modus aus.',
|
||||
'User declined to answer the questions.':
|
||||
'Benutzer hat die Beantwortung der Fragen abgelehnt.',
|
||||
'User has provided the following answers:':
|
||||
'Benutzer hat die folgenden Antworten bereitgestellt:',
|
||||
'Failed to process user answers:':
|
||||
'Fehler beim Verarbeiten der Benutzerantworten:',
|
||||
'Type something...': 'Etwas eingeben...',
|
||||
Submit: 'Senden',
|
||||
'Submit answers': 'Antworten senden',
|
||||
Cancel: 'Abbrechen',
|
||||
'Your answers:': 'Ihre Antworten:',
|
||||
'(not answered)': '(nicht beantwortet)',
|
||||
'Ready to submit your answers?': 'Bereit, Ihre Antworten zu senden?',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select':
|
||||
'↑/↓: Navigieren | ←/→: Tabs wechseln | Enter: Auswählen',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: Navigieren | ←/→: Tabs wechseln | Space/Enter: Umschalten | Esc: Abbrechen',
|
||||
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: Navigieren | Space/Enter: Umschalten | Esc: Abbrechen',
|
||||
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
|
||||
'↑/↓: Navigieren | Enter: Auswählen | Esc: Abbrechen',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'list available Qwen Code tools. Usage: /tools [desc]',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:',
|
||||
'No tools available': 'No tools available',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -289,6 +289,73 @@ export default {
|
|||
'Failed to save and edit subagent: {{error}}':
|
||||
'Failed to save and edit subagent: {{error}}',
|
||||
|
||||
// ============================================================================
|
||||
// Extensions - Management Dialog
|
||||
// ============================================================================
|
||||
'Manage Extensions': 'Manage Extensions',
|
||||
'Extension Details': 'Extension Details',
|
||||
'View Extension': 'View Extension',
|
||||
'Update Extension': 'Update Extension',
|
||||
'Disable Extension': 'Disable Extension',
|
||||
'Enable Extension': 'Enable Extension',
|
||||
'Uninstall Extension': 'Uninstall Extension',
|
||||
'Select Scope': 'Select Scope',
|
||||
'User Scope': 'User Scope',
|
||||
'Workspace Scope': 'Workspace Scope',
|
||||
'No extensions found.': 'No extensions found.',
|
||||
Active: 'Active',
|
||||
Disabled: 'Disabled',
|
||||
'Update available': 'Update available',
|
||||
'Up to date': 'Up to date',
|
||||
'Checking...': 'Checking...',
|
||||
'Updating...': 'Updating...',
|
||||
Unknown: 'Unknown',
|
||||
Error: 'Error',
|
||||
'Version:': 'Version:',
|
||||
'Status:': 'Status:',
|
||||
'Are you sure you want to uninstall extension "{{name}}"?':
|
||||
'Are you sure you want to uninstall extension "{{name}}"?',
|
||||
'This action cannot be undone.': 'This action cannot be undone.',
|
||||
'Extension "{{name}}" disabled successfully.':
|
||||
'Extension "{{name}}" disabled successfully.',
|
||||
'Extension "{{name}}" enabled successfully.':
|
||||
'Extension "{{name}}" enabled successfully.',
|
||||
'Extension "{{name}}" updated successfully.':
|
||||
'Extension "{{name}}" updated successfully.',
|
||||
'Failed to update extension "{{name}}": {{error}}':
|
||||
'Failed to update extension "{{name}}": {{error}}',
|
||||
'Select the scope for this action:': 'Select the scope for this action:',
|
||||
'User - Applies to all projects': 'User - Applies to all projects',
|
||||
'Workspace - Applies to current project only':
|
||||
'Workspace - Applies to current project only',
|
||||
// Extension dialog - missing keys
|
||||
'Name:': 'Name:',
|
||||
'MCP Servers:': 'MCP Servers:',
|
||||
'Settings:': 'Settings:',
|
||||
active: 'active',
|
||||
disabled: 'disabled',
|
||||
'View Details': 'View Details',
|
||||
'Update failed:': 'Update failed:',
|
||||
'Updating {{name}}...': 'Updating {{name}}...',
|
||||
'Update complete!': 'Update complete!',
|
||||
'User (global)': 'User (global)',
|
||||
'Workspace (project-specific)': 'Workspace (project-specific)',
|
||||
'Disable "{{name}}" - Select Scope': 'Disable "{{name}}" - Select Scope',
|
||||
'Enable "{{name}}" - Select Scope': 'Enable "{{name}}" - Select Scope',
|
||||
'No extension selected': 'No extension selected',
|
||||
'Press Y/Enter to confirm, N/Esc to cancel':
|
||||
'Press Y/Enter to confirm, N/Esc to cancel',
|
||||
'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter to confirm, N/Esc to cancel',
|
||||
'{{count}} extensions installed': '{{count}} extensions installed',
|
||||
"Use '/extensions install' to install your first extension.":
|
||||
"Use '/extensions install' to install your first extension.",
|
||||
// Update status values
|
||||
'up to date': 'up to date',
|
||||
'update available': 'update available',
|
||||
'checking...': 'checking...',
|
||||
'not updatable': 'not updatable',
|
||||
error: 'error',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - General (continued)
|
||||
// ============================================================================
|
||||
|
|
@ -376,8 +443,10 @@ export default {
|
|||
'Show tool-specific usage statistics.':
|
||||
'Show tool-specific usage statistics.',
|
||||
'exit the cli': 'exit the cli',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
'Manage workspace directories': 'Manage workspace directories',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
'Add directories to the workspace. Use comma to separate multiple paths',
|
||||
|
|
@ -726,6 +795,7 @@ export default {
|
|||
'List configured MCP servers and tools':
|
||||
'List configured MCP servers and tools',
|
||||
'Restarts MCP servers.': 'Restarts MCP servers.',
|
||||
'Open MCP management dialog': 'Open MCP management dialog',
|
||||
'Config not loaded.': 'Config not loaded.',
|
||||
'Could not retrieve tool registry.': 'Could not retrieve tool registry.',
|
||||
'No MCP servers configured with OAuth authentication.':
|
||||
|
|
@ -742,6 +812,96 @@ export default {
|
|||
"Re-discovering tools from '{{name}}'...":
|
||||
"Re-discovering tools from '{{name}}'...",
|
||||
|
||||
// ============================================================================
|
||||
// MCP Management Dialog
|
||||
// ============================================================================
|
||||
'Manage MCP servers': 'Manage MCP servers',
|
||||
'Server Detail': 'Server Detail',
|
||||
'Disable Server': 'Disable Server',
|
||||
Tools: 'Tools',
|
||||
'Tool Detail': 'Tool Detail',
|
||||
'MCP Management': 'MCP Management',
|
||||
'Loading...': 'Loading...',
|
||||
'Unknown step': 'Unknown step',
|
||||
'Esc to back': 'Esc to back',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ to navigate · Enter to select · Esc to close',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ to navigate · Enter to select · Esc to back',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back',
|
||||
'User Settings (global)': 'User Settings (global)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'Workspace Settings (project-specific)',
|
||||
'Disable server:': 'Disable server:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'Select where to add the server to the exclude list:',
|
||||
'Press Enter to confirm, Esc to cancel':
|
||||
'Press Enter to confirm, Esc to cancel',
|
||||
'View tools': 'View tools',
|
||||
Reconnect: 'Reconnect',
|
||||
Enable: 'Enable',
|
||||
Disable: 'Disable',
|
||||
'Command:': 'Command:',
|
||||
'Working Directory:': 'Working Directory:',
|
||||
'Capabilities:': 'Capabilities:',
|
||||
'No server selected': 'No server selected',
|
||||
prompts: 'prompts',
|
||||
'(disabled)': '(disabled)',
|
||||
'Error:': 'Error:',
|
||||
Extension: 'Extension',
|
||||
tool: 'tool',
|
||||
tools: 'tools',
|
||||
connected: 'connected',
|
||||
connecting: 'connecting',
|
||||
disconnected: 'disconnected',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'User MCPs',
|
||||
'Project MCPs': 'Project MCPs',
|
||||
'Extension MCPs': 'Extension MCPs',
|
||||
server: 'server',
|
||||
servers: 'servers',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'Add MCP servers to your settings to get started.',
|
||||
'Run qwen --debug to see error logs': 'Run qwen --debug to see error logs',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.': 'No tools available for this server.',
|
||||
destructive: 'destructive',
|
||||
'read-only': 'read-only',
|
||||
'open-world': 'open-world',
|
||||
idempotent: 'idempotent',
|
||||
'Tools for {{name}}': 'Tools for {{name}}',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: 'required',
|
||||
Type: 'Type',
|
||||
Enum: 'Enum',
|
||||
Parameters: 'Parameters',
|
||||
'No tool selected': 'No tool selected',
|
||||
Annotations: 'Annotations',
|
||||
Title: 'Title',
|
||||
'Read Only': 'Read Only',
|
||||
Destructive: 'Destructive',
|
||||
Idempotent: 'Idempotent',
|
||||
'Open World': 'Open World',
|
||||
Server: 'Server',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} invalid tools',
|
||||
invalid: 'invalid',
|
||||
'invalid: {{reason}}': 'invalid: {{reason}}',
|
||||
'missing name': 'missing name',
|
||||
'missing description': 'missing description',
|
||||
'(unnamed)': '(unnamed)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'Warning: This tool cannot be called by the LLM',
|
||||
Reason: 'Reason',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'Tools must have both name and description to be used by the LLM.',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Chat
|
||||
// ============================================================================
|
||||
|
|
@ -874,6 +1034,7 @@ export default {
|
|||
'Do you want to proceed?': 'Do you want to proceed?',
|
||||
'Yes, allow once': 'Yes, allow once',
|
||||
'Allow always': 'Allow always',
|
||||
Yes: 'Yes',
|
||||
No: 'No',
|
||||
'No (esc)': 'No (esc)',
|
||||
'Yes, allow always for this session': 'Yes, allow always for this session',
|
||||
|
|
@ -1434,6 +1595,16 @@ export default {
|
|||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Coding Plan': 'Coding Plan',
|
||||
"Paste your api key of Bailian Coding Plan and you're all set!":
|
||||
"Paste your api key of Bailian Coding Plan and you're all set!",
|
||||
Custom: 'Custom',
|
||||
'More instructions about configuring `modelProviders` manually.':
|
||||
'More instructions about configuring `modelProviders` manually.',
|
||||
'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:',
|
||||
'(Press Escape to go back)': '(Press Escape to go back)',
|
||||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Press Enter to submit, Escape to cancel)',
|
||||
'Select Region for Coding Plan': 'Select Region for Coding Plan',
|
||||
'Choose based on where your account is registered':
|
||||
'Choose based on where your account is registered',
|
||||
|
|
@ -1446,6 +1617,38 @@ export default {
|
|||
'New model configurations are available for {{region}}. Update now?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'{{region}} configuration updated successfully.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'Tip: Use /model to switch between available Coding Plan models.',
|
||||
|
||||
// ============================================================================
|
||||
// Ask User Question Tool
|
||||
// ============================================================================
|
||||
'Please answer the following question(s):':
|
||||
'Please answer the following question(s):',
|
||||
'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.':
|
||||
'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.',
|
||||
'User declined to answer the questions.':
|
||||
'User declined to answer the questions.',
|
||||
'User has provided the following answers:':
|
||||
'User has provided the following answers:',
|
||||
'Failed to process user answers:': 'Failed to process user answers:',
|
||||
'Type something...': 'Type something...',
|
||||
Submit: 'Submit',
|
||||
'Submit answers': 'Submit answers',
|
||||
Cancel: 'Cancel',
|
||||
'Your answers:': 'Your answers:',
|
||||
'(not answered)': '(not answered)',
|
||||
'Ready to submit your answers?': 'Ready to submit your answers?',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select':
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel',
|
||||
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel',
|
||||
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
|
||||
'↑/↓: Navigate | Enter: Select | Esc: Cancel',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:',
|
||||
'No tools available': '利用可能なツールはありません',
|
||||
|
|
@ -317,7 +317,9 @@ export default {
|
|||
'セッション統計を確認。使い方: /stats [model|tools]',
|
||||
'Show model-specific usage statistics.': 'モデル別の使用統計を表示',
|
||||
'Show tool-specific usage statistics.': 'ツール別の使用統計を表示',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'MCP管理ダイアログを開く、またはOAuth対応サーバーで認証',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'設定済みのMCPサーバーとツールを一覧表示、またはOAuth対応サーバーで認証',
|
||||
'Manage workspace directories': 'ワークスペースディレクトリを管理',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
|
|
@ -622,9 +624,101 @@ export default {
|
|||
'Do you want to proceed?': '続行しますか?',
|
||||
'Yes, allow once': 'はい(今回のみ許可)',
|
||||
'Allow always': '常に許可する',
|
||||
Yes: 'はい',
|
||||
No: 'いいえ',
|
||||
'No (esc)': 'いいえ (Esc)',
|
||||
'Yes, allow always for this session': 'はい、このセッションで常に許可',
|
||||
|
||||
// MCP Management - Core translations
|
||||
'Manage MCP servers': 'MCPサーバーを管理',
|
||||
'Server Detail': 'サーバー詳細',
|
||||
'Disable Server': 'サーバーを無効化',
|
||||
Tools: 'ツール',
|
||||
'Tool Detail': 'ツール詳細',
|
||||
'MCP Management': 'MCP管理',
|
||||
'Loading...': '読み込み中...',
|
||||
'Unknown step': '不明なステップ',
|
||||
'Esc to back': 'Esc 戻る',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ ナビゲート · Enter 選択 · Esc 閉じる',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ ナビゲート · Enter 選択 · Esc 戻る',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ ナビゲート · Enter 確認 · Esc 戻る',
|
||||
'User Settings (global)': 'ユーザー設定(グローバル)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'ワークスペース設定(プロジェクト固有)',
|
||||
'Disable server:': 'サーバーを無効化:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'サーバーを除外リストに追加する場所を選択してください:',
|
||||
'Press Enter to confirm, Esc to cancel': 'Enter で確認、Esc でキャンセル',
|
||||
Disable: '無効化',
|
||||
Enable: '有効化',
|
||||
Reconnect: '再接続',
|
||||
'View tools': 'ツールを表示',
|
||||
'Status:': 'ステータス:',
|
||||
'Source:': 'ソース:',
|
||||
'Command:': 'コマンド:',
|
||||
'Working Directory:': '作業ディレクトリ:',
|
||||
'Capabilities:': '機能:',
|
||||
'No server selected': 'サーバーが選択されていません',
|
||||
'(disabled)': '(無効)',
|
||||
'Error:': 'エラー:',
|
||||
Extension: '拡張機能',
|
||||
tool: 'ツール',
|
||||
tools: 'ツール',
|
||||
connected: '接続済み',
|
||||
connecting: '接続中',
|
||||
disconnected: '切断済み',
|
||||
error: 'エラー',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'ユーザーMCP',
|
||||
'Project MCPs': 'プロジェクトMCP',
|
||||
'Extension MCPs': '拡張機能MCP',
|
||||
server: 'サーバー',
|
||||
servers: 'サーバー',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'設定にMCPサーバーを追加して開始してください。',
|
||||
'Run qwen --debug to see error logs':
|
||||
'qwen --debug を実行してエラーログを確認してください',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.':
|
||||
'このサーバーには使用可能なツールがありません。',
|
||||
destructive: '破壊的',
|
||||
'read-only': '読み取り専用',
|
||||
'open-world': 'オープンワールド',
|
||||
idempotent: '冪等',
|
||||
'Tools for {{name}}': '{{name}} のツール',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: '必須',
|
||||
Type: '型',
|
||||
Enum: '列挙',
|
||||
Parameters: 'パラメータ',
|
||||
'No tool selected': 'ツールが選択されていません',
|
||||
Annotations: '注釈',
|
||||
Title: 'タイトル',
|
||||
'Read Only': '読み取り専用',
|
||||
Destructive: '破壊的',
|
||||
Idempotent: '冪等',
|
||||
'Open World': 'オープンワールド',
|
||||
Server: 'サーバー',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} 個の無効なツール',
|
||||
invalid: '無効',
|
||||
'invalid: {{reason}}': '無効: {{reason}}',
|
||||
'missing name': '名前なし',
|
||||
'missing description': '説明なし',
|
||||
'(unnamed)': '(名前なし)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'警告: このツールはLLMによって呼び出すことができません',
|
||||
Reason: '理由',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'ツールはLLMによって使用されるには名前と説明の両方が必要です。',
|
||||
'Modify in progress:': '変更中:',
|
||||
'Save and close external editor to continue':
|
||||
'続行するには外部エディタを保存して閉じてください',
|
||||
|
|
@ -952,6 +1046,17 @@ export default {
|
|||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Coding Plan': 'Coding Plan',
|
||||
"Paste your api key of Bailian Coding Plan and you're all set!":
|
||||
'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!',
|
||||
Custom: 'カスタム',
|
||||
'More instructions about configuring `modelProviders` manually.':
|
||||
'`modelProviders`を手動で設定する方法の詳細はこちら。',
|
||||
'Select API-KEY configuration mode:': 'API-KEY設定モードを選択してください:',
|
||||
'(Press Escape to go back)': '(Escapeキーで戻る)',
|
||||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Enterで送信、Escapeでキャンセル)',
|
||||
'More instructions please check:': '詳細な手順はこちらをご確認ください:',
|
||||
'Select Region for Coding Plan': 'Coding Planのリージョンを選択',
|
||||
'Choose based on where your account is registered':
|
||||
'アカウントの登録先に応じて選択してください',
|
||||
|
|
@ -964,6 +1069,37 @@ export default {
|
|||
'{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'{{region}} の設定が正常に更新されました。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました。',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'ヒント: /model で利用可能な Coding Plan モデルを切り替えられます。',
|
||||
|
||||
// ============================================================================
|
||||
// Ask User Question Tool
|
||||
// ============================================================================
|
||||
'Please answer the following question(s):': '以下の質問に答えてください:',
|
||||
'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.':
|
||||
'非対話モードではユーザーに質問できません。このツールを使用するには対話モードで実行してください。',
|
||||
'User declined to answer the questions.':
|
||||
'ユーザーは質問への回答を拒否しました。',
|
||||
'User has provided the following answers:':
|
||||
'ユーザーは以下の回答を提供しました:',
|
||||
'Failed to process user answers:': 'ユーザー回答の処理に失敗しました:',
|
||||
'Type something...': '何か入力...',
|
||||
Submit: '送信',
|
||||
'Submit answers': '回答を送信',
|
||||
Cancel: 'キャンセル',
|
||||
'Your answers:': 'あなたの回答:',
|
||||
'(not answered)': '(未回答)',
|
||||
'Ready to submit your answers?': '回答を送信しますか?',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select':
|
||||
'↑/↓: ナビゲート | ←/→: タブ切り替え | Enter: 選択',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: ナビゲート | ←/→: タブ切り替え | Space/Enter: 切り替え | Esc: キャンセル',
|
||||
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: ナビゲート | Space/Enter: 切り替え | Esc: キャンセル',
|
||||
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
|
||||
'↑/↓: ナビゲート | Enter: 選択 | Esc: キャンセル',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'Analisa o projeto e cria um arquivo QWEN.md personalizado.',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:',
|
||||
'No tools available': 'Nenhuma ferramenta disponível',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -385,8 +385,10 @@ export default {
|
|||
'Show tool-specific usage statistics.':
|
||||
'Mostrar estatísticas de uso específicas da ferramenta.',
|
||||
'exit the cli': 'sair da cli',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth',
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'Abrir diálogo de gerenciamento MCP ou autenticar com servidor habilitado para OAuth',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth',
|
||||
'Manage workspace directories': 'Gerenciar diretórios do workspace',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
'Adicionar diretórios ao workspace. Use vírgula para separar vários caminhos',
|
||||
|
|
@ -888,9 +890,102 @@ export default {
|
|||
'Do you want to proceed?': 'Você deseja prosseguir?',
|
||||
'Yes, allow once': 'Sim, permitir uma vez',
|
||||
'Allow always': 'Permitir sempre',
|
||||
Yes: 'Sim',
|
||||
No: 'Não',
|
||||
'No (esc)': 'Não (esc)',
|
||||
'Yes, allow always for this session': 'Sim, permitir sempre para esta sessão',
|
||||
|
||||
// MCP Management - Core translations
|
||||
'Manage MCP servers': 'Gerenciar servidores MCP',
|
||||
'Server Detail': 'Detalhes do servidor',
|
||||
'Disable Server': 'Desativar servidor',
|
||||
Tools: 'Ferramentas',
|
||||
'Tool Detail': 'Detalhes da ferramenta',
|
||||
'MCP Management': 'Gerenciamento MCP',
|
||||
'Loading...': 'Carregando...',
|
||||
'Unknown step': 'Etapa desconhecida',
|
||||
'Esc to back': 'Esc para voltar',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ navegar · Enter selecionar · Esc fechar',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ navegar · Enter selecionar · Esc voltar',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ navegar · Enter confirmar · Esc voltar',
|
||||
'User Settings (global)': 'Configurações do usuário (global)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'Configurações do workspace (específico do projeto)',
|
||||
'Disable server:': 'Desativar servidor:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'Selecione onde adicionar o servidor à lista de exclusão:',
|
||||
'Press Enter to confirm, Esc to cancel':
|
||||
'Enter para confirmar, Esc para cancelar',
|
||||
Disable: 'Desativar',
|
||||
Enable: 'Ativar',
|
||||
Reconnect: 'Reconectar',
|
||||
'View tools': 'Ver ferramentas',
|
||||
'Status:': 'Status:',
|
||||
'Source:': 'Fonte:',
|
||||
'Command:': 'Comando:',
|
||||
'Working Directory:': 'Diretório de trabalho:',
|
||||
'Capabilities:': 'Capacidades:',
|
||||
'No server selected': 'Nenhum servidor selecionado',
|
||||
'(disabled)': '(desativado)',
|
||||
'Error:': 'Erro:',
|
||||
Extension: 'Extensão',
|
||||
tool: 'ferramenta',
|
||||
tools: 'ferramentas',
|
||||
connected: 'conectado',
|
||||
connecting: 'conectando',
|
||||
disconnected: 'desconectado',
|
||||
error: 'erro',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'MCPs do usuário',
|
||||
'Project MCPs': 'MCPs do projeto',
|
||||
'Extension MCPs': 'MCPs de extensão',
|
||||
server: 'servidor',
|
||||
servers: 'servidores',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'Adicione servidores MCP às suas configurações para começar.',
|
||||
'Run qwen --debug to see error logs':
|
||||
'Execute qwen --debug para ver os logs de erro',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.':
|
||||
'Nenhuma ferramenta disponível para este servidor.',
|
||||
destructive: 'destrutivo',
|
||||
'read-only': 'somente leitura',
|
||||
'open-world': 'mundo aberto',
|
||||
idempotent: 'idempotente',
|
||||
'Tools for {{name}}': 'Ferramentas para {{name}}',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: 'obrigatório',
|
||||
Type: 'Tipo',
|
||||
Enum: 'Enumeração',
|
||||
Parameters: 'Parâmetros',
|
||||
'No tool selected': 'Nenhuma ferramenta selecionada',
|
||||
Annotations: 'Anotações',
|
||||
Title: 'Título',
|
||||
'Read Only': 'Somente leitura',
|
||||
Destructive: 'Destrutivo',
|
||||
Idempotent: 'Idempotente',
|
||||
'Open World': 'Mundo aberto',
|
||||
Server: 'Servidor',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} ferramentas inválidas',
|
||||
invalid: 'inválido',
|
||||
'invalid: {{reason}}': 'inválido: {{reason}}',
|
||||
'missing name': 'nome ausente',
|
||||
'missing description': 'descrição ausente',
|
||||
'(unnamed)': '(sem nome)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'Aviso: Esta ferramenta não pode ser chamada pelo LLM',
|
||||
Reason: 'Motivo',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'As ferramentas devem ter tanto nome quanto descrição para serem usadas pelo LLM.',
|
||||
'Modify in progress:': 'Modificação em progresso:',
|
||||
'Save and close external editor to continue':
|
||||
'Salve e feche o editor externo para continuar',
|
||||
|
|
@ -1439,6 +1534,18 @@ export default {
|
|||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Coding Plan': 'Coding Plan',
|
||||
"Paste your api key of Bailian Coding Plan and you're all set!":
|
||||
'Cole sua chave de API do Bailian Coding Plan e pronto!',
|
||||
Custom: 'Personalizado',
|
||||
'More instructions about configuring `modelProviders` manually.':
|
||||
'Mais instruções sobre como configurar `modelProviders` manualmente.',
|
||||
'Select API-KEY configuration mode:':
|
||||
'Selecione o modo de configuração da API-KEY:',
|
||||
'(Press Escape to go back)': '(Pressione Escape para voltar)',
|
||||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Pressione Enter para enviar, Escape para cancelar)',
|
||||
'More instructions please check:': 'Mais instruções, consulte:',
|
||||
'Select Region for Coding Plan': 'Selecionar região do Coding Plan',
|
||||
'Choose based on where your account is registered':
|
||||
'Escolha com base em onde sua conta está registrada',
|
||||
|
|
@ -1451,6 +1558,39 @@ export default {
|
|||
'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'Configuração do {{region}} atualizada com sucesso.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json.',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'Dica: Use /model para alternar entre os modelos disponíveis do Coding Plan.',
|
||||
|
||||
// ============================================================================
|
||||
// Ask User Question Tool
|
||||
// ============================================================================
|
||||
'Please answer the following question(s):':
|
||||
'Por favor, responda à(s) seguinte(s) pergunta(s):',
|
||||
'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.':
|
||||
'Não é possível fazer perguntas ao usuário no modo não interativo. Por favor, execute no modo interativo para usar esta ferramenta.',
|
||||
'User declined to answer the questions.':
|
||||
'O usuário recusou responder às perguntas.',
|
||||
'User has provided the following answers:':
|
||||
'O usuário forneceu as seguintes respostas:',
|
||||
'Failed to process user answers:':
|
||||
'Falha ao processar as respostas do usuário:',
|
||||
'Type something...': 'Digite algo...',
|
||||
Submit: 'Enviar',
|
||||
'Submit answers': 'Enviar respostas',
|
||||
Cancel: 'Cancelar',
|
||||
'Your answers:': 'Suas respostas:',
|
||||
'(not answered)': '(não respondido)',
|
||||
'Ready to submit your answers?': 'Pronto para enviar suas respostas?',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select':
|
||||
'↑/↓: Navegar | ←/→: Alternar abas | Enter: Selecionar',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: Navegar | ←/→: Alternar abas | Space/Enter: Alternar | Esc: Cancelar',
|
||||
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: Navegar | Space/Enter: Alternar | Esc: Cancelar',
|
||||
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
|
||||
'↑/↓: Navegar | Enter: Selecionar | Esc: Cancelar',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'Анализ проекта и создание адаптированного файла QWEN.md',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:',
|
||||
'No tools available': 'Нет доступных инструментов',
|
||||
|
|
@ -380,7 +380,9 @@ export default {
|
|||
'Show tool-specific usage statistics.':
|
||||
'Показать статистику использования инструментов.',
|
||||
'exit the cli': 'Выход из CLI',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'Открыть диалог управления MCP или авторизоваться на сервере с поддержкой OAuth',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth',
|
||||
'Manage workspace directories':
|
||||
'Управление директориями рабочего пространства',
|
||||
|
|
@ -889,9 +891,36 @@ export default {
|
|||
'Do you want to proceed?': 'Вы хотите продолжить?',
|
||||
'Yes, allow once': 'Да, разрешить один раз',
|
||||
'Allow always': 'Всегда разрешать',
|
||||
Yes: 'Да',
|
||||
No: 'Нет',
|
||||
'No (esc)': 'Нет (esc)',
|
||||
'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии',
|
||||
|
||||
// MCP Management - Core translations
|
||||
Disable: 'Отключить',
|
||||
Enable: 'Включить',
|
||||
Reconnect: 'Переподключить',
|
||||
'View tools': 'Просмотреть инструменты',
|
||||
'(disabled)': '(отключен)',
|
||||
'Error:': 'Ошибка:',
|
||||
Extension: 'Расширение',
|
||||
tool: 'инструмент',
|
||||
connected: 'подключен',
|
||||
connecting: 'подключение',
|
||||
disconnected: 'отключен',
|
||||
error: 'ошибка',
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} недействительных инструментов',
|
||||
invalid: 'недействительный',
|
||||
'invalid: {{reason}}': 'недействительно: {{reason}}',
|
||||
'missing name': 'отсутствует имя',
|
||||
'missing description': 'отсутствует описание',
|
||||
'(unnamed)': '(без имени)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'Предупреждение: Этот инструмент не может быть вызван LLM',
|
||||
Reason: 'Причина',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'Инструменты должны иметь как имя, так и описание, чтобы использоваться LLM.',
|
||||
'Modify in progress:': 'Идет изменение:',
|
||||
'Save and close external editor to continue':
|
||||
'Сохраните и закройте внешний редактор для продолжения',
|
||||
|
|
@ -1449,6 +1478,17 @@ export default {
|
|||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'Coding Plan': 'Coding Plan',
|
||||
"Paste your api key of Bailian Coding Plan and you're all set!":
|
||||
'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!',
|
||||
Custom: 'Пользовательский',
|
||||
'More instructions about configuring `modelProviders` manually.':
|
||||
'Дополнительные инструкции по ручной настройке `modelProviders`.',
|
||||
'Select API-KEY configuration mode:': 'Выберите режим конфигурации API-KEY:',
|
||||
'(Press Escape to go back)': '(Нажмите Escape для возврата)',
|
||||
'(Press Enter to submit, Escape to cancel)':
|
||||
'(Нажмите Enter для отправки, Escape для отмены)',
|
||||
'More instructions please check:': 'Дополнительные инструкции см.:',
|
||||
'Select Region for Coding Plan': 'Выберите регион Coding Plan',
|
||||
'Choose based on where your account is registered':
|
||||
'Выберите в зависимости от места регистрации вашего аккаунта',
|
||||
|
|
@ -1463,4 +1503,106 @@ export default {
|
|||
'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
|
||||
|
||||
// ============================================================================
|
||||
// MCP Management Dialog
|
||||
// ============================================================================
|
||||
'MCP Management': 'Управление MCP',
|
||||
'Server List': 'Список серверов',
|
||||
'Server Detail': 'Детали сервера',
|
||||
'Disable Server': 'Отключить сервер',
|
||||
'Tool List': 'Список инструментов',
|
||||
'Tool Detail': 'Детали инструмента',
|
||||
'Loading...': 'Загрузка...',
|
||||
'Unknown step': 'Неизвестный шаг',
|
||||
'Esc to back': 'Esc для возврата',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ навигация · Enter выбрать · Esc закрыть',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ навигация · Enter выбрать · Esc назад',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ навигация · Enter подтвердить · Esc назад',
|
||||
'User Settings (global)': 'Настройки пользователя (глобальные)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'Настройки рабочего пространства (проектные)',
|
||||
'Disable server:': 'Отключить сервер:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'Выберите, где добавить сервер в список исключений:',
|
||||
'Press Enter to confirm, Esc to cancel':
|
||||
'Enter для подтверждения, Esc для отмены',
|
||||
'Status:': 'Статус:',
|
||||
'Command:': 'Команда:',
|
||||
'Working Directory:': 'Рабочий каталог:',
|
||||
'Capabilities:': 'Возможности:',
|
||||
'No server selected': 'Сервер не выбран',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'MCP пользователя',
|
||||
'Project MCPs': 'MCP проекта',
|
||||
'Extension MCPs': 'MCP расширений',
|
||||
server: 'сервер',
|
||||
servers: 'серверов',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'Добавьте серверы MCP в настройки, чтобы начать.',
|
||||
'Run qwen --debug to see error logs':
|
||||
'Запустите qwen --debug для просмотра журналов ошибок',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.':
|
||||
'Для этого сервера нет доступных инструментов.',
|
||||
destructive: 'деструктивный',
|
||||
'read-only': 'только чтение',
|
||||
'open-world': 'открытый мир',
|
||||
idempotent: 'идемпотентный',
|
||||
'Tools for {{name}}': 'Инструменты для {{name}}',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: 'обязательный',
|
||||
Type: 'Тип',
|
||||
Enum: 'Перечисление',
|
||||
Parameters: 'Параметры',
|
||||
'No tool selected': 'Инструмент не выбран',
|
||||
Annotations: 'Аннотации',
|
||||
Title: 'Заголовок',
|
||||
'Read Only': 'Только чтение',
|
||||
Destructive: 'Деструктивный',
|
||||
Idempotent: 'Идемпотентный',
|
||||
'Open World': 'Открытый мир',
|
||||
Server: 'Сервер',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'Конфигурация {{region}} успешно обновлена.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json.',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'Совет: Используйте /model для переключения между доступными моделями Coding Plan.',
|
||||
|
||||
// ============================================================================
|
||||
// Ask User Question Tool
|
||||
// ============================================================================
|
||||
'Please answer the following question(s):':
|
||||
'Пожалуйста, ответьте на следующий(ие) вопрос(ы):',
|
||||
'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.':
|
||||
'Невозможно задавать вопросы пользователю в неинтерактивном режиме. Пожалуйста, запустите в интерактивном режиме для использования этого инструмента.',
|
||||
'User declined to answer the questions.':
|
||||
'Пользователь отказался отвечать на вопросы.',
|
||||
'User has provided the following answers:':
|
||||
'Пользователь предоставил следующие ответы:',
|
||||
'Failed to process user answers:':
|
||||
'Не удалось обработать ответы пользователя:',
|
||||
'Type something...': 'Введите что-то...',
|
||||
Submit: 'Отправить',
|
||||
'Submit answers': 'Отправить ответы',
|
||||
Cancel: 'Отмена',
|
||||
'Your answers:': 'Ваши ответы:',
|
||||
'(not answered)': '(не отвечено)',
|
||||
'Ready to submit your answers?': 'Готовы отправить свои ответы?',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select':
|
||||
'↑/↓: Навигация | ←/→: Переключение вкладок | Enter: Выбор',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: Навигация | ←/→: Переключение вкладок | Space/Enter: Переключить | Esc: Отмена',
|
||||
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: Навигация | Space/Enter: Переключить | Esc: Отмена',
|
||||
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
|
||||
'↑/↓: Навигация | Enter: Выбор | Esc: Отмена',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default {
|
|||
'!': '!',
|
||||
'!npm run start': '!npm run start',
|
||||
'start server': 'start server',
|
||||
'Commands:': '命令:',
|
||||
'Commands:': '命令:',
|
||||
'shell command': 'shell 命令',
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'模型上下文协议命令(来自外部服务器)',
|
||||
|
|
@ -114,7 +114,7 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'分析项目并创建定制的 QWEN.md 文件',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'列出可用的 Qwen Code 工具。用法:/tools [desc]',
|
||||
'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:',
|
||||
'No tools available': '没有可用工具',
|
||||
|
|
@ -278,6 +278,68 @@ export default {
|
|||
'Failed to save and edit subagent: {{error}}':
|
||||
'保存并编辑子智能体失败: {{error}}',
|
||||
|
||||
// ============================================================================
|
||||
// Extensions - Management Dialog
|
||||
// ============================================================================
|
||||
'Manage Extensions': '管理扩展',
|
||||
'Extension Details': '扩展详情',
|
||||
'View Extension': '查看扩展',
|
||||
'Update Extension': '更新扩展',
|
||||
'Disable Extension': '禁用扩展',
|
||||
'Enable Extension': '启用扩展',
|
||||
'Uninstall Extension': '卸载扩展',
|
||||
'Select Scope': '选择作用域',
|
||||
'User Scope': '用户作用域',
|
||||
'Workspace Scope': '工作区作用域',
|
||||
'No extensions found.': '未找到扩展。',
|
||||
Active: '已启用',
|
||||
Disabled: '已禁用',
|
||||
'Update available': '有可用更新',
|
||||
'Up to date': '已是最新',
|
||||
'Checking...': '检查中...',
|
||||
'Updating...': '更新中...',
|
||||
Unknown: '未知',
|
||||
Error: '错误',
|
||||
'Version:': '版本:',
|
||||
'Status:': '状态:',
|
||||
'Are you sure you want to uninstall extension "{{name}}"?':
|
||||
'确定要卸载扩展 "{{name}}" 吗?',
|
||||
'This action cannot be undone.': '此操作无法撤销。',
|
||||
'Extension "{{name}}" disabled successfully.': '扩展 "{{name}}" 禁用成功。',
|
||||
'Extension "{{name}}" enabled successfully.': '扩展 "{{name}}" 启用成功。',
|
||||
'Extension "{{name}}" updated successfully.': '扩展 "{{name}}" 更新成功。',
|
||||
'Failed to update extension "{{name}}": {{error}}':
|
||||
'更新扩展 "{{name}}" 失败:{{error}}',
|
||||
'Select the scope for this action:': '选择此操作的作用域:',
|
||||
'User - Applies to all projects': '用户 - 应用于所有项目',
|
||||
'Workspace - Applies to current project only': '工作区 - 仅应用于当前项目',
|
||||
// Extension dialog - missing keys
|
||||
'Name:': '名称:',
|
||||
'MCP Servers:': 'MCP 服务器:',
|
||||
'Settings:': '设置:',
|
||||
active: '已启用',
|
||||
disabled: '已禁用',
|
||||
'View Details': '查看详情',
|
||||
'Update failed:': '更新失败:',
|
||||
'Updating {{name}}...': '正在更新 {{name}}...',
|
||||
'Update complete!': '更新完成!',
|
||||
'User (global)': '用户(全局)',
|
||||
'Workspace (project-specific)': '工作区(项目特定)',
|
||||
'Disable "{{name}}" - Select Scope': '禁用 "{{name}}" - 选择作用域',
|
||||
'Enable "{{name}}" - Select Scope': '启用 "{{name}}" - 选择作用域',
|
||||
'No extension selected': '未选择扩展',
|
||||
'Press Y/Enter to confirm, N/Esc to cancel': '按 Y/Enter 确认,N/Esc 取消',
|
||||
'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter 确认,N/Esc 取消',
|
||||
'{{count}} extensions installed': '已安装 {{count}} 个扩展',
|
||||
"Use '/extensions install' to install your first extension.":
|
||||
"使用 '/extensions install' 安装您的第一个扩展。",
|
||||
// Update status values
|
||||
'up to date': '已是最新',
|
||||
'update available': '有可用更新',
|
||||
'checking...': '检查中...',
|
||||
'not updatable': '不可更新',
|
||||
error: '错误',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - General (continued)
|
||||
// ============================================================================
|
||||
|
|
@ -361,7 +423,9 @@ export default {
|
|||
'Show model-specific usage statistics.': '显示模型相关的使用统计信息',
|
||||
'Show tool-specific usage statistics.': '显示工具相关的使用统计信息',
|
||||
'exit the cli': '退出命令行界面',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'打开 MCP 管理对话框,或在支持 OAuth 的服务器上进行身份验证',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'列出已配置的 MCP 服务器和工具,或使用支持 OAuth 的服务器进行身份验证',
|
||||
'Manage workspace directories': '管理工作区目录',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
|
|
@ -685,6 +749,7 @@ export default {
|
|||
'使用支持 OAuth 的 MCP 服务器进行认证',
|
||||
'List configured MCP servers and tools': '列出已配置的 MCP 服务器和工具',
|
||||
'Restarts MCP servers.': '重启 MCP 服务器',
|
||||
'Open MCP management dialog': '打开 MCP 管理对话框',
|
||||
'Config not loaded.': '配置未加载',
|
||||
'Could not retrieve tool registry.': '无法检索工具注册表',
|
||||
'No MCP servers configured with OAuth authentication.':
|
||||
|
|
@ -700,6 +765,92 @@ export default {
|
|||
"Re-discovering tools from '{{name}}'...":
|
||||
"正在重新发现 '{{name}}' 的工具...",
|
||||
|
||||
// ============================================================================
|
||||
// MCP Management Dialog
|
||||
// ============================================================================
|
||||
'Manage MCP servers': '管理 MCP 服务器',
|
||||
'Server Detail': '服务器详情',
|
||||
'Disable Server': '禁用服务器',
|
||||
Tools: '工具',
|
||||
'Tool Detail': '工具详情',
|
||||
'MCP Management': 'MCP 管理',
|
||||
'Loading...': '加载中...',
|
||||
'Unknown step': '未知步骤',
|
||||
'Esc to back': 'Esc 返回',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ 导航 · Enter 选择 · Esc 关闭',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ 导航 · Enter 选择 · Esc 返回',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ 导航 · Enter 确认 · Esc 返回',
|
||||
'User Settings (global)': '用户设置(全局)',
|
||||
'Workspace Settings (project-specific)': '工作区设置(项目级)',
|
||||
'Disable server:': '禁用服务器:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'选择将服务器添加到排除列表的位置:',
|
||||
'Press Enter to confirm, Esc to cancel': '按 Enter 确认,Esc 取消',
|
||||
'View tools': '查看工具',
|
||||
Reconnect: '重新连接',
|
||||
Enable: '启用',
|
||||
Disable: '禁用',
|
||||
'(disabled)': '(已禁用)',
|
||||
'Error:': '错误:',
|
||||
Extension: '扩展',
|
||||
tool: '工具',
|
||||
tools: '个工具',
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
disconnected: '已断开',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': '用户 MCP',
|
||||
'Project MCPs': '项目 MCP',
|
||||
'Extension MCPs': '扩展 MCP',
|
||||
server: '个服务器',
|
||||
servers: '个服务器',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'请在设置中添加 MCP 服务器以开始使用。',
|
||||
'Run qwen --debug to see error logs': '运行 qwen --debug 查看错误日志',
|
||||
|
||||
// MCP Server Detail
|
||||
'Command:': '命令:',
|
||||
'Working Directory:': '工作目录:',
|
||||
'Capabilities:': '功能:',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.': '此服务器没有可用工具。',
|
||||
destructive: '破坏性',
|
||||
'read-only': '只读',
|
||||
'open-world': '开放世界',
|
||||
idempotent: '幂等',
|
||||
'Tools for {{name}}': '{{name}} 的工具',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
Type: '类型',
|
||||
Parameters: '参数',
|
||||
'No tool selected': '未选择工具',
|
||||
Annotations: '注解',
|
||||
Title: '标题',
|
||||
'Read Only': '只读',
|
||||
Destructive: '破坏性',
|
||||
Idempotent: '幂等',
|
||||
'Open World': '开放世界',
|
||||
Server: '服务器',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} 个无效工具',
|
||||
invalid: '无效',
|
||||
'invalid: {{reason}}': '无效:{{reason}}',
|
||||
'missing name': '缺少名称',
|
||||
'missing description': '缺少描述',
|
||||
'(unnamed)': '(未命名)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'警告:此工具无法被 LLM 调用',
|
||||
Reason: '原因',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'工具必须同时具有名称和描述才能被 LLM 使用。',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Chat
|
||||
// ============================================================================
|
||||
|
|
@ -825,6 +976,7 @@ export default {
|
|||
'Do you want to proceed?': '是否继续?',
|
||||
'Yes, allow once': '是,允许一次',
|
||||
'Allow always': '总是允许',
|
||||
Yes: '是',
|
||||
No: '否',
|
||||
'No (esc)': '否 (esc)',
|
||||
'Yes, allow always for this session': '是,本次会话总是允许',
|
||||
|
|
@ -1267,6 +1419,16 @@ export default {
|
|||
// ============================================================================
|
||||
// Auth Dialog - View Titles and Labels
|
||||
// ============================================================================
|
||||
'API-KEY': 'API-KEY',
|
||||
'Coding Plan': 'Coding Plan',
|
||||
"Paste your api key of Bailian Coding Plan and you're all set!":
|
||||
'粘贴您的百炼 Coding Plan API Key,即可完成设置!',
|
||||
Custom: '自定义',
|
||||
'More instructions about configuring `modelProviders` manually.':
|
||||
'关于手动配置 `modelProviders` 的更多说明。',
|
||||
'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:',
|
||||
'(Press Escape to go back)': '(按 Escape 键返回)',
|
||||
'(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)',
|
||||
'Select Region for Coding Plan': '选择 Coding Plan 区域',
|
||||
'Choose based on where your account is registered':
|
||||
'请根据您的账号注册地区选择',
|
||||
|
|
@ -1279,6 +1441,34 @@ export default {
|
|||
'{{region}} 有新的模型配置可用。是否立即更新?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'{{region}} 配置更新成功。模型已切换至 "{{model}}"。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。',
|
||||
'{{region}} configuration updated successfully.': '{{region}} 配置更新成功。',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。',
|
||||
'Tip: Use /model to switch between available Coding Plan models.':
|
||||
'提示:使用 /model 切换可用的 Coding Plan 模型。',
|
||||
|
||||
// ============================================================================
|
||||
// Ask User Question Tool
|
||||
// ============================================================================
|
||||
'Please answer the following question(s):': '请回答以下问题:',
|
||||
'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.':
|
||||
'无法在非交互模式下询问用户问题。请在交互模式下运行以使用此工具。',
|
||||
'User declined to answer the questions.': '用户拒绝回答问题。',
|
||||
'User has provided the following answers:': '用户提供了以下答案:',
|
||||
'Failed to process user answers:': '处理用户答案失败:',
|
||||
'Type something...': '输入内容...',
|
||||
Submit: '提交',
|
||||
'Submit answers': '提交答案',
|
||||
Cancel: '取消',
|
||||
'Your answers:': '您的答案:',
|
||||
'(not answered)': '(未回答)',
|
||||
'Ready to submit your answers?': '准备好提交您的答案了吗?',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select':
|
||||
'↑/↓: 导航 | ←/→: 切换标签页 | Enter: 选择',
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: 导航 | ←/→: 切换标签页 | Space/Enter: 切换 | Esc: 取消',
|
||||
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel':
|
||||
'↑/↓: 导航 | Space/Enter: 切换 | Esc: 取消',
|
||||
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
|
||||
'↑/↓: 导航 | Enter: 选择 | Esc: 取消',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { editorCommand } from '../ui/commands/editorCommand.js';
|
|||
import { exportCommand } from '../ui/commands/exportCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { languageCommand } from '../ui/commands/languageCommand.js';
|
||||
|
|
@ -74,6 +75,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
exportCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
hooksCommand,
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
languageCommand,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,30 @@ This is a test prompt from markdown.`;
|
|||
}
|
||||
});
|
||||
|
||||
it('should load markdown commands with BOM and CRLF frontmatter', async () => {
|
||||
const mdContent =
|
||||
'\uFEFF---\r\ndescription: Windows markdown command\r\n---\r\n\r\nPrompt from windows markdown.\r\n';
|
||||
|
||||
const commandPath = path.join(tempDir, 'windows-command.md');
|
||||
await fs.writeFile(commandPath, mdContent, 'utf-8');
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const originalMethod = loader['getCommandDirectories'];
|
||||
loader['getCommandDirectories'] = () => [{ path: tempDir }];
|
||||
|
||||
try {
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const windowsCommand = commands.find(
|
||||
(cmd) => cmd.name === 'windows-command',
|
||||
);
|
||||
|
||||
expect(windowsCommand).toBeDefined();
|
||||
expect(windowsCommand?.description).toBe('Windows markdown command');
|
||||
} finally {
|
||||
loader['getCommandDirectories'] = originalMethod;
|
||||
}
|
||||
});
|
||||
|
||||
it('should load both toml and markdown commands', async () => {
|
||||
// Create both TOML and Markdown files
|
||||
const tomlContent = `prompt = "TOML prompt"
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ import type {
|
|||
InsightProgressCallback,
|
||||
} from '../types/StaticInsightTypes.js';
|
||||
|
||||
import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const logger = createDebugLogger('StaticInsightGenerator');
|
||||
import { updateSymlink, type Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class StaticInsightGenerator {
|
||||
private dataProcessor: DataProcessor;
|
||||
|
|
@ -54,40 +52,12 @@ export class StaticInsightGenerator {
|
|||
return outputPath;
|
||||
}
|
||||
|
||||
// Create or update the "latest" alias (symlink preferred, copy as fallback)
|
||||
private async updateLatestAlias(
|
||||
private async updateInsightSymlink(
|
||||
outputDir: string,
|
||||
targetPath: string,
|
||||
): Promise<void> {
|
||||
const latestPath = path.join(outputDir, 'insight.html');
|
||||
const relativeTarget = path.relative(outputDir, targetPath);
|
||||
|
||||
// Remove existing file/symlink if it exists
|
||||
try {
|
||||
await fs.unlink(latestPath);
|
||||
} catch {
|
||||
// File doesn't exist, ignore
|
||||
}
|
||||
|
||||
// Try symlink first (preferred - lightweight, always points to latest)
|
||||
try {
|
||||
await fs.symlink(relativeTarget, latestPath);
|
||||
logger.debug('Created insight symlink:', relativeTarget);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Failed to create insight symlink, falling back to copy:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: copy file (works everywhere, uses more disk space)
|
||||
try {
|
||||
await fs.copyFile(targetPath, latestPath);
|
||||
logger.debug('Created insight copy:', targetPath);
|
||||
} catch (error) {
|
||||
logger.debug('Failed to create insight latest alias:', error);
|
||||
}
|
||||
await updateSymlink(latestPath, targetPath);
|
||||
}
|
||||
|
||||
// Generate the static insight HTML file
|
||||
|
|
@ -116,8 +86,7 @@ export class StaticInsightGenerator {
|
|||
// Write the HTML file
|
||||
await fs.writeFile(outputPath, html, 'utf-8');
|
||||
|
||||
// Update latest alias (symlink preferred, copy as fallback)
|
||||
await this.updateLatestAlias(outputDir, outputPath);
|
||||
await this.updateInsightSymlink(outputDir, outputPath);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,51 @@ Prompt content.`;
|
|||
expect(result.frontmatter).toBeDefined();
|
||||
expect(result.prompt).toBe('Prompt content.');
|
||||
});
|
||||
|
||||
it('should parse frontmatter in CRLF files', () => {
|
||||
const content =
|
||||
'---\r\ndescription: Windows command\r\n---\r\n\r\nLine 1\r\nLine 2\r\n';
|
||||
|
||||
const result = parseMarkdownCommand(content);
|
||||
|
||||
expect(result).toEqual({
|
||||
frontmatter: {
|
||||
description: 'Windows command',
|
||||
},
|
||||
prompt: 'Line 1\nLine 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse frontmatter in CR-only files', () => {
|
||||
const content =
|
||||
'---\rdescription: Old mac command\r---\r\rLine 1\rLine 2\r';
|
||||
|
||||
const result = parseMarkdownCommand(content);
|
||||
|
||||
expect(result).toEqual({
|
||||
frontmatter: {
|
||||
description: 'Old mac command',
|
||||
},
|
||||
prompt: 'Line 1\nLine 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse frontmatter when content starts with UTF-8 BOM', () => {
|
||||
const content = `\uFEFF---
|
||||
description: BOM command
|
||||
---
|
||||
|
||||
Prompt from BOM file.`;
|
||||
|
||||
const result = parseMarkdownCommand(content);
|
||||
|
||||
expect(result).toEqual({
|
||||
frontmatter: {
|
||||
description: 'BOM command',
|
||||
},
|
||||
prompt: 'Prompt from BOM file.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownCommandDefSchema', () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { parse as parseYaml } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
parse as parseYaml,
|
||||
normalizeContent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Defines the Zod schema for a Markdown command definition file.
|
||||
|
|
@ -31,19 +34,21 @@ export type MarkdownCommandDef = z.infer<typeof MarkdownCommandDefSchema>;
|
|||
* @returns Parsed command definition with frontmatter and prompt
|
||||
*/
|
||||
export function parseMarkdownCommand(content: string): MarkdownCommandDef {
|
||||
const normalizedContent = normalizeContent(content);
|
||||
|
||||
// Match YAML frontmatter pattern: ---\n...\n---\n
|
||||
// Allow empty frontmatter: ---\n---\n // Use (?:[\s\S]*?) to make the frontmatter content optional
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)---\n([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
// Allow empty frontmatter: ---\n---\n
|
||||
const frontmatterRegex = /^---\n(?:([\s\S]*?)\n)?---(?:\n|$)([\s\S]*)$/;
|
||||
const match = normalizedContent.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
// No frontmatter, entire content is the prompt
|
||||
return {
|
||||
prompt: content.trim(),
|
||||
prompt: normalizedContent.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const [, frontmatterYaml, body] = match;
|
||||
const [, frontmatterYaml = '', body] = match;
|
||||
|
||||
// Parse YAML frontmatter if not empty
|
||||
let frontmatter: Record<string, unknown> | undefined;
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ import { useDialogClose } from './hooks/useDialogClose.js';
|
|||
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||
import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js';
|
||||
import { useMcpDialog } from './hooks/useMcpDialog.js';
|
||||
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||
import {
|
||||
requestConsentInteractive,
|
||||
|
|
@ -498,6 +500,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openAgentsManagerDialog,
|
||||
closeAgentsManagerDialog,
|
||||
} = useAgentsManagerDialog();
|
||||
const {
|
||||
isExtensionsManagerDialogOpen,
|
||||
openExtensionsManagerDialog,
|
||||
closeExtensionsManagerDialog,
|
||||
} = useExtensionsManagerDialog();
|
||||
const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog();
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
|
|
@ -521,6 +529,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openResumeDialog,
|
||||
}),
|
||||
[
|
||||
|
|
@ -537,6 +547,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openResumeDialog,
|
||||
],
|
||||
);
|
||||
|
|
@ -1338,8 +1350,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
showIdeRestartPrompt ||
|
||||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isMcpDialogOpen ||
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
isResumeDialogOpen ||
|
||||
isExtensionsManagerDialogOpen;
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
|
|
@ -1450,6 +1464,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Extensions manager dialog
|
||||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
isMcpDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
}),
|
||||
|
|
@ -1541,6 +1559,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Extensions manager dialog
|
||||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
isMcpDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
],
|
||||
|
|
@ -1585,6 +1607,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Extensions manager dialog
|
||||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
closeMcpDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
|
|
@ -1631,6 +1657,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Extensions manager dialog
|
||||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
closeMcpDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
|
|
|
|||
|
|
@ -389,13 +389,24 @@ export const useAuthCommand = (
|
|||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.',
|
||||
{ region: t('Alibaba Cloud Coding Plan') },
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Hint about /model command
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t(
|
||||
'Tip: Use /model to switch between available Coding Plan models.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Log success
|
||||
const authEvent = new AuthEvent(
|
||||
AuthType.USE_OPENAI,
|
||||
|
|
|
|||
|
|
@ -150,7 +150,8 @@ Memory Usage: 100 MB`;
|
|||
Runtime: Node.js v20.0.0 / npm 10.0.0
|
||||
IDE Client: VSCode
|
||||
OS: test-platform x64 (22.0.0)
|
||||
Auth: ${AuthType.USE_OPENAI} (https://api.openai.com/v1)
|
||||
Auth: API Key - ${AuthType.USE_OPENAI}
|
||||
Base URL: https://api.openai.com/v1
|
||||
Model: qwen3-coder-plus
|
||||
Session ID: test-session-id
|
||||
Sandbox: test
|
||||
|
|
|
|||
|
|
@ -16,9 +16,8 @@ import {
|
|||
beforeEach,
|
||||
type MockedFunction,
|
||||
} from 'vitest';
|
||||
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||
|
||||
import {
|
||||
type Extension,
|
||||
ExtensionManager,
|
||||
parseInstallSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -33,24 +32,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||
});
|
||||
|
||||
const mockGetExtensions = vi.fn();
|
||||
const mockUpdateExtension = vi.fn();
|
||||
const mockUpdateAllUpdatableExtensions = vi.fn();
|
||||
const mockCheckForAllExtensionUpdates = vi.fn();
|
||||
const mockInstallExtension = vi.fn();
|
||||
const mockUninstallExtension = vi.fn();
|
||||
const mockGetLoadedExtensions = vi.fn();
|
||||
const mockEnableExtension = vi.fn();
|
||||
const mockDisableExtension = vi.fn();
|
||||
const mockInstallExtension = vi.fn();
|
||||
|
||||
const createMockExtensionManager = () => ({
|
||||
updateExtension: mockUpdateExtension,
|
||||
updateAllUpdatableExtensions: mockUpdateAllUpdatableExtensions,
|
||||
checkForAllExtensionUpdates: mockCheckForAllExtensionUpdates,
|
||||
installExtension: mockInstallExtension,
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
getLoadedExtensions: mockGetLoadedExtensions,
|
||||
enableExtension: mockEnableExtension,
|
||||
disableExtension: mockDisableExtension,
|
||||
});
|
||||
|
||||
describe('extensionsCommand', () => {
|
||||
|
|
@ -62,7 +49,6 @@ describe('extensionsCommand', () => {
|
|||
mockExtensionManager = createMockExtensionManager();
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
mockGetLoadedExtensions.mockReturnValue([]);
|
||||
mockCheckForAllExtensionUpdates.mockResolvedValue(undefined);
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
|
|
@ -78,334 +64,57 @@ describe('extensionsCommand', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should add an EXTENSIONS_LIST item to the UI when extensions exist', async () => {
|
||||
describe('default action (manage)', () => {
|
||||
it('should open extensions manager dialog when extensions exist', async () => {
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]);
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
const result = await extensionsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'extensions_manage',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show info message when no extensions installed', async () => {
|
||||
it('should open extensions manager dialog when no extensions installed', async () => {
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
const result = await extensionsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No extensions installed.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'extensions_manage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const updateAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'update',
|
||||
describe('manage', () => {
|
||||
const manageAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'manage',
|
||||
)?.action;
|
||||
|
||||
if (!updateAction) {
|
||||
throw new Error('Update action not found');
|
||||
if (!manageAction) {
|
||||
throw new Error('Manage action not found');
|
||||
}
|
||||
|
||||
it('should show usage if no args are provided', async () => {
|
||||
await updateAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions update <extension-names>|--all',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
it('should return dialog action for extensions manager', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]);
|
||||
const result = await manageAction(mockContext, '');
|
||||
|
||||
it('should inform user if there are no extensions to update with --all', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue([]);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No extensions to update.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call setPendingItem and addItem in a finally block on success', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue([
|
||||
{
|
||||
name: 'ext-one',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.1',
|
||||
},
|
||||
{
|
||||
name: 'ext-two',
|
||||
originalVersion: '2.0.0',
|
||||
updatedVersion: '2.0.1',
|
||||
},
|
||||
]);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call setPendingItem and addItem in a finally block on failure', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
|
||||
mockUpdateAllUpdatableExtensions.mockRejectedValue(
|
||||
new Error('Something went wrong'),
|
||||
);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Something went wrong',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a single extension by name', async () => {
|
||||
const extension: Extension = {
|
||||
id: 'ext-one',
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
contextFiles: [],
|
||||
config: { name: 'ext-one', version: '1.0.0' },
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
mockUpdateExtension.mockResolvedValue({
|
||||
name: extension.name,
|
||||
originalVersion: extension.version,
|
||||
updatedVersion: '1.0.1',
|
||||
});
|
||||
mockGetExtensions.mockReturnValue([extension]);
|
||||
mockContext.ui.extensionsUpdateState.set(extension.name, {
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
processed: false,
|
||||
});
|
||||
await updateAction(mockContext, 'ext-one');
|
||||
expect(mockUpdateExtension).toHaveBeenCalledWith(
|
||||
extension,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when updating a single extension', async () => {
|
||||
// Provide at least one extension so we don't get "No extensions installed" message
|
||||
const otherExtension: Extension = {
|
||||
id: 'other-ext',
|
||||
name: 'other-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/other-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'other-ext', version: '1.0.0' },
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([otherExtension]);
|
||||
await updateAction(mockContext, 'ext-one');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Extension "ext-one" not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update multiple extensions by name', async () => {
|
||||
const extensionOne: Extension = {
|
||||
id: 'ext-one',
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
contextFiles: [],
|
||||
config: { name: 'ext-one', version: '1.0.0' },
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const extensionTwo: Extension = {
|
||||
id: 'ext-two',
|
||||
name: 'ext-two',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-two',
|
||||
contextFiles: [],
|
||||
config: { name: 'ext-two', version: '1.0.0' },
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
|
||||
mockContext.ui.extensionsUpdateState.set(extensionOne.name, {
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
processed: false,
|
||||
});
|
||||
mockContext.ui.extensionsUpdateState.set(extensionTwo.name, {
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
processed: false,
|
||||
});
|
||||
mockUpdateExtension
|
||||
.mockResolvedValueOnce({
|
||||
name: 'ext-one',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
name: 'ext-two',
|
||||
originalVersion: '2.0.0',
|
||||
updatedVersion: '2.0.1',
|
||||
});
|
||||
await updateAction(mockContext, 'ext-one ext-two');
|
||||
expect(mockUpdateExtension).toHaveBeenCalledTimes(2);
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
const updateCompletion = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'update',
|
||||
)?.completion;
|
||||
|
||||
if (!updateCompletion) {
|
||||
throw new Error('Update completion not found');
|
||||
}
|
||||
|
||||
const extensionOne: Extension = {
|
||||
id: 'ext-one',
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
contextFiles: [],
|
||||
config: { name: 'ext-one', version: '1.0.0' },
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const extensionTwo: Extension = {
|
||||
id: 'another-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'another-ext', version: '1.0.0' },
|
||||
name: 'another-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/another-ext',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const allExt: Extension = {
|
||||
id: 'all-ext',
|
||||
name: 'all-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'all-ext', version: '1.0.0' },
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/all-ext',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'should return matching extension names',
|
||||
extensions: [extensionOne, extensionTwo],
|
||||
partialArg: 'ext',
|
||||
expected: ['ext-one'],
|
||||
},
|
||||
{
|
||||
description: 'should return --all when partialArg matches',
|
||||
extensions: [],
|
||||
partialArg: '--al',
|
||||
expected: ['--all'],
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should return both extension names and --all when both match',
|
||||
extensions: [allExt],
|
||||
partialArg: 'all',
|
||||
expected: ['--all', 'all-ext'],
|
||||
},
|
||||
{
|
||||
description: 'should return an empty array if no matches',
|
||||
extensions: [extensionOne],
|
||||
partialArg: 'nomatch',
|
||||
expected: [],
|
||||
},
|
||||
])('$description', async ({ extensions, partialArg, expected }) => {
|
||||
mockGetExtensions.mockReturnValue(extensions);
|
||||
const suggestions = await updateCompletion(mockContext, partialArg);
|
||||
expect(suggestions).toEqual(expected);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'extensions_manage',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call reloadCommands in finally block', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue([
|
||||
{
|
||||
name: 'ext-one',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.1',
|
||||
},
|
||||
]);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
|
||||
it('should return dialog action even when no extensions installed', async () => {
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
const result = await manageAction(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'extensions_manage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -501,363 +210,4 @@ describe('extensionsCommand', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
const uninstallAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'uninstall',
|
||||
)?.action;
|
||||
|
||||
if (!uninstallAction) {
|
||||
throw new Error('Uninstall action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.uninstallExtension = mockUninstallExtension;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if no name is provided', async () => {
|
||||
await uninstallAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions uninstall <extension-name>',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should uninstall extension successfully', async () => {
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
|
||||
await uninstallAction(mockContext, 'test-extension');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Uninstalling extension "test-extension"...',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
false,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" uninstalled successfully.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle uninstall errors', async () => {
|
||||
mockUninstallExtension.mockRejectedValue(
|
||||
new Error('Extension not found.'),
|
||||
);
|
||||
|
||||
await uninstallAction(mockContext, 'nonexistent-extension');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to uninstall extension "nonexistent-extension": Extension not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable', () => {
|
||||
const disableAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
)?.action;
|
||||
|
||||
if (!disableAction) {
|
||||
throw new Error('Disable action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.disableExtension = mockDisableExtension;
|
||||
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/extensions disable',
|
||||
name: 'disable',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if invalid args are provided', async () => {
|
||||
await disableAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions disable <extension> [--scope=<user|workspace>]',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable extension at user scope', async () => {
|
||||
mockDisableExtension.mockResolvedValue(undefined);
|
||||
|
||||
await disableAction(mockContext, 'test-extension --scope=user');
|
||||
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
'User',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" disabled for scope "User"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable extension at workspace scope', async () => {
|
||||
mockDisableExtension.mockResolvedValue(undefined);
|
||||
|
||||
await disableAction(mockContext, 'test-extension --scope workspace');
|
||||
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
'Workspace',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" disabled for scope "Workspace"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error for invalid scope', async () => {
|
||||
await disableAction(mockContext, 'test-extension --scope=invalid');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable', () => {
|
||||
const enableAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'enable',
|
||||
)?.action;
|
||||
|
||||
if (!enableAction) {
|
||||
throw new Error('Enable action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.enableExtension = mockEnableExtension;
|
||||
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/extensions enable',
|
||||
name: 'enable',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if invalid args are provided', async () => {
|
||||
await enableAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions enable <extension> [--scope=<user|workspace>]',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable extension at user scope', async () => {
|
||||
mockEnableExtension.mockResolvedValue(undefined);
|
||||
|
||||
await enableAction(mockContext, 'test-extension --scope=user');
|
||||
|
||||
expect(mockEnableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
'User',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" enabled for scope "User"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable extension at workspace scope', async () => {
|
||||
mockEnableExtension.mockResolvedValue(undefined);
|
||||
|
||||
await enableAction(mockContext, 'test-extension --scope workspace');
|
||||
|
||||
expect(mockEnableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
'Workspace',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" enabled for scope "Workspace"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error for invalid scope', async () => {
|
||||
await enableAction(mockContext, 'test-extension --scope=invalid');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail', () => {
|
||||
const detailAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'detail',
|
||||
)?.action;
|
||||
|
||||
if (!detailAction) {
|
||||
throw new Error('Detail action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/extensions detail',
|
||||
name: 'detail',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if no name is provided', async () => {
|
||||
await detailAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions detail <extension-name>',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if extension not found', async () => {
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
await detailAction(mockContext, 'nonexistent-extension');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Extension "nonexistent-extension" not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show extension details when found', async () => {
|
||||
const extension: Extension = {
|
||||
id: 'test-ext',
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/test-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'test-ext', version: '1.0.0' },
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([extension]);
|
||||
realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true);
|
||||
|
||||
await detailAction(mockContext, 'test-ext');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('test-ext'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
|
|
@ -16,12 +15,9 @@ import { t } from '../../i18n/index.js';
|
|||
import {
|
||||
ExtensionManager,
|
||||
parseInstallSource,
|
||||
type ExtensionUpdateInfo,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import open from 'open';
|
||||
import { extensionToOutputString } from '../../commands/extensions/utils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('EXTENSIONS_COMMAND');
|
||||
const EXTENSION_EXPLORE_URL = {
|
||||
|
|
@ -31,23 +27,6 @@ const EXTENSION_EXPLORE_URL = {
|
|||
|
||||
type ExtensionExploreSource = keyof typeof EXTENSION_EXPLORE_URL;
|
||||
|
||||
function showMessageIfNoExtensions(
|
||||
context: CommandContext,
|
||||
extensions: unknown[],
|
||||
): boolean {
|
||||
if (extensions.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('No extensions installed.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function exploreAction(context: CommandContext, args: string) {
|
||||
const source = args.trim();
|
||||
const extensionsUrl = source
|
||||
|
|
@ -113,130 +92,11 @@ async function exploreAction(context: CommandContext, args: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function listAction(context: CommandContext) {
|
||||
const extensions = context.services.config
|
||||
? context.services.config.getExtensions()
|
||||
: [];
|
||||
|
||||
if (showMessageIfNoExtensions(context, extensions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
async function updateAction(context: CommandContext, args: string) {
|
||||
const updateArgs = args.split(' ').filter((value) => value.length > 0);
|
||||
const all = updateArgs.length === 1 && updateArgs[0] === '--all';
|
||||
const names = all ? undefined : updateArgs;
|
||||
|
||||
if (!all && names?.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Usage: /extensions update <extension-names>|--all'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let updateInfos: ExtensionUpdateInfo[] = [];
|
||||
|
||||
const extensionManager = context.services.config!.getExtensionManager();
|
||||
const extensions = context.services.config
|
||||
? context.services.config.getExtensions()
|
||||
: [];
|
||||
|
||||
if (showMessageIfNoExtensions(context, extensions)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_START' });
|
||||
await extensionManager.checkForAllExtensionUpdates((extensionName, state) =>
|
||||
context.ui.dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extensionName, state },
|
||||
}),
|
||||
);
|
||||
context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_END' });
|
||||
|
||||
context.ui.setPendingItem({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
if (all) {
|
||||
updateInfos = await extensionManager.updateAllUpdatableExtensions(
|
||||
context.ui.extensionsUpdateState,
|
||||
(extensionName, state) =>
|
||||
context.ui.dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extensionName, state },
|
||||
}),
|
||||
);
|
||||
} else if (names?.length) {
|
||||
const extensions = context.services.config!.getExtensions();
|
||||
for (const name of names) {
|
||||
const extension = extensions.find(
|
||||
(extension) => extension.name === name,
|
||||
);
|
||||
if (!extension) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Extension "{{name}}" not found.', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const updateInfo = await extensionManager.updateExtension(
|
||||
extension,
|
||||
context.ui.extensionsUpdateState.get(extension.name)?.status ??
|
||||
ExtensionUpdateState.UNKNOWN,
|
||||
(extensionName, state) =>
|
||||
context.ui.dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extensionName, state },
|
||||
}),
|
||||
);
|
||||
if (updateInfo) updateInfos.push(updateInfo);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateInfos.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('No extensions to update.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: getErrorMessage(error),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} finally {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.reloadCommands();
|
||||
context.ui.setPendingItem(null);
|
||||
}
|
||||
async function listAction(_context: CommandContext, _args: string) {
|
||||
return {
|
||||
type: 'dialog' as const,
|
||||
dialog: 'extensions_manage' as const,
|
||||
};
|
||||
}
|
||||
|
||||
async function installAction(context: CommandContext, args: string) {
|
||||
|
|
@ -296,235 +156,6 @@ async function installAction(context: CommandContext, args: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function uninstallAction(context: CommandContext, args: string) {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Usage: /extensions uninstall <extension-name>'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Uninstalling extension "{{name}}"...', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
try {
|
||||
await extensionManager.uninstallExtension(name, false);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Extension "{{name}}" uninstalled successfully.', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.reloadCommands();
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to uninstall extension "{{name}}": {{error}}', {
|
||||
name,
|
||||
error: getErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getEnableDisableContext(
|
||||
context: CommandContext,
|
||||
argumentsString: string,
|
||||
): {
|
||||
extensionManager: ExtensionManager;
|
||||
names: string[];
|
||||
scope: SettingScope;
|
||||
} | null {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const parts = argumentsString.split(' ');
|
||||
const name = parts[0];
|
||||
if (
|
||||
name === '' ||
|
||||
!(
|
||||
(parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope=<scope>
|
||||
(parts.length === 3 && parts[1] === '--scope') // --scope <scope>
|
||||
)
|
||||
) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t(
|
||||
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]',
|
||||
{
|
||||
command: context.invocation?.name ?? '',
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
let scope: SettingScope;
|
||||
// Transform `--scope=<scope>` to `--scope <scope>`.
|
||||
if (parts.length === 2) {
|
||||
parts.push(...parts[1].split('='));
|
||||
parts.splice(1, 1);
|
||||
}
|
||||
switch (parts[2].toLowerCase()) {
|
||||
case 'workspace':
|
||||
scope = SettingScope.Workspace;
|
||||
break;
|
||||
case 'user':
|
||||
scope = SettingScope.User;
|
||||
break;
|
||||
default:
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t(
|
||||
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"',
|
||||
{
|
||||
scope: parts[2],
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
let names: string[] = [];
|
||||
if (name === '--all') {
|
||||
let extensions = extensionManager.getLoadedExtensions();
|
||||
if (context.invocation?.name === 'enable') {
|
||||
extensions = extensions.filter((ext) => !ext.isActive);
|
||||
}
|
||||
if (context.invocation?.name === 'disable') {
|
||||
extensions = extensions.filter((ext) => ext.isActive);
|
||||
}
|
||||
names = extensions.map((ext) => ext.name);
|
||||
} else {
|
||||
names = [name];
|
||||
}
|
||||
|
||||
return {
|
||||
extensionManager,
|
||||
names,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
async function disableAction(context: CommandContext, args: string) {
|
||||
const enableContext = getEnableDisableContext(context, args);
|
||||
if (!enableContext) return;
|
||||
|
||||
const { names, scope, extensionManager } = enableContext;
|
||||
for (const name of names) {
|
||||
await extensionManager.disableExtension(name, scope);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Extension "{{name}}" disabled for scope "{{scope}}"', {
|
||||
name,
|
||||
scope,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.reloadCommands();
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAction(context: CommandContext, args: string) {
|
||||
const enableContext = getEnableDisableContext(context, args);
|
||||
if (!enableContext) return;
|
||||
|
||||
const { names, scope, extensionManager } = enableContext;
|
||||
for (const name of names) {
|
||||
await extensionManager.enableExtension(name, scope);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Extension "{{name}}" enabled for scope "{{scope}}"', {
|
||||
name,
|
||||
scope,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.reloadCommands();
|
||||
}
|
||||
}
|
||||
|
||||
async function detailAction(context: CommandContext, args: string) {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Usage: /extensions detail <extension-name>'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const extensions = context.services.config!.getExtensions();
|
||||
const extension = extensions.find((extension) => extension.name === name);
|
||||
if (!extension) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Extension "{{name}}" not found.', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: extensionToOutputString(
|
||||
extension,
|
||||
extensionManager,
|
||||
process.cwd(),
|
||||
true,
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeExtensions(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
|
|
@ -589,45 +220,15 @@ const exploreExtensionsCommand: SlashCommand = {
|
|||
completion: completeExtensionsExplore,
|
||||
};
|
||||
|
||||
const listExtensionsCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
const manageExtensionsCommand: SlashCommand = {
|
||||
name: 'manage',
|
||||
get description() {
|
||||
return t('List active extensions');
|
||||
return t('Manage installed extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
const updateExtensionsCommand: SlashCommand = {
|
||||
name: 'update',
|
||||
get description() {
|
||||
return t('Update extensions. Usage: update <extension-names>|--all');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: updateAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
const disableCommand: SlashCommand = {
|
||||
name: 'disable',
|
||||
get description() {
|
||||
return t('Disable an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: disableAction,
|
||||
completion: completeExtensionsAndScopes,
|
||||
};
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'enable',
|
||||
get description() {
|
||||
return t('Enable an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: enableAction,
|
||||
completion: completeExtensionsAndScopes,
|
||||
};
|
||||
|
||||
const installCommand: SlashCommand = {
|
||||
name: 'install',
|
||||
get description() {
|
||||
|
|
@ -637,26 +238,6 @@ const installCommand: SlashCommand = {
|
|||
action: installAction,
|
||||
};
|
||||
|
||||
const uninstallCommand: SlashCommand = {
|
||||
name: 'uninstall',
|
||||
get description() {
|
||||
return t('Uninstall an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: uninstallAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
const detailCommand: SlashCommand = {
|
||||
name: 'detail',
|
||||
get description() {
|
||||
return t('Get detail of an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: detailAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions',
|
||||
get description() {
|
||||
|
|
@ -664,16 +245,11 @@ export const extensionsCommand: SlashCommand = {
|
|||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
disableCommand,
|
||||
enableCommand,
|
||||
manageExtensionsCommand,
|
||||
installCommand,
|
||||
uninstallCommand,
|
||||
exploreExtensionsCommand,
|
||||
detailCommand,
|
||||
],
|
||||
action: (context, args) =>
|
||||
action: async (context, args) =>
|
||||
// Default to list if no subcommand is provided
|
||||
listExtensionsCommand.action!(context, args),
|
||||
manageExtensionsCommand.action!(context, args),
|
||||
};
|
||||
|
|
|
|||
322
packages/cli/src/ui/commands/hooksCommand.ts
Normal file
322
packages/cli/src/ui/commands/hooksCommand.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
CommandContext,
|
||||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type { HookRegistryEntry } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Format hook source for display
|
||||
*/
|
||||
function formatHookSource(source: string): string {
|
||||
switch (source) {
|
||||
case 'project':
|
||||
return 'Project';
|
||||
case 'user':
|
||||
return 'User';
|
||||
case 'system':
|
||||
return 'System';
|
||||
case 'extensions':
|
||||
return 'Extension';
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format hook status for display
|
||||
*/
|
||||
function formatHookStatus(enabled: boolean): string {
|
||||
return enabled ? '✓ Enabled' : '✗ Disabled';
|
||||
}
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
get description() {
|
||||
return t('List all configured hooks');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t(
|
||||
'Hooks are not enabled. Enable hooks in settings to use this feature.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
const allHooks = registry.getAllHooks();
|
||||
|
||||
if (allHooks.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t(
|
||||
'No hooks configured. Add hooks in your settings.json file.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Group hooks by event
|
||||
const hooksByEvent = new Map<string, HookRegistryEntry[]>();
|
||||
for (const hook of allHooks) {
|
||||
const eventName = hook.eventName;
|
||||
if (!hooksByEvent.has(eventName)) {
|
||||
hooksByEvent.set(eventName, []);
|
||||
}
|
||||
hooksByEvent.get(eventName)!.push(hook);
|
||||
}
|
||||
|
||||
let output = `**Configured Hooks (${allHooks.length} total)**\n\n`;
|
||||
|
||||
for (const [eventName, hooks] of hooksByEvent) {
|
||||
output += `### ${eventName}\n`;
|
||||
for (const hook of hooks) {
|
||||
const name = hook.config.name || hook.config.command || 'unnamed';
|
||||
const source = formatHookSource(hook.source);
|
||||
const status = formatHookStatus(hook.enabled);
|
||||
const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : '';
|
||||
output += `- **${name}** [${source}] ${status}${matcher}\n`;
|
||||
}
|
||||
output += '\n';
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: output,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'enable',
|
||||
get description() {
|
||||
return t('Enable a disabled hook');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const hookName = args.trim();
|
||||
if (!hookName) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Please specify a hook name. Usage: /hooks enable <hook-name>',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Hooks are not enabled.'),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
registry.setHookEnabled(hookName, true);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Hook "{{name}}" has been enabled for this session.', {
|
||||
name: hookName,
|
||||
}),
|
||||
};
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) return [];
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
const allHooks = registry.getAllHooks();
|
||||
|
||||
// Return disabled hooks for enable command (deduplicated by name)
|
||||
const disabledHookNames = allHooks
|
||||
.filter((hook) => !hook.enabled)
|
||||
.map((hook) => hook.config.name || hook.config.command || '')
|
||||
.filter((name) => name && name.startsWith(partialArg));
|
||||
return [...new Set(disabledHookNames)];
|
||||
},
|
||||
};
|
||||
|
||||
const disableCommand: SlashCommand = {
|
||||
name: 'disable',
|
||||
get description() {
|
||||
return t('Disable an active hook');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const hookName = args.trim();
|
||||
if (!hookName) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Please specify a hook name. Usage: /hooks disable <hook-name>',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Hooks are not enabled.'),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
registry.setHookEnabled(hookName, false);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: t('Hook "{{name}}" has been disabled for this session.', {
|
||||
name: hookName,
|
||||
}),
|
||||
};
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
if (!hookSystem) return [];
|
||||
|
||||
const registry = hookSystem.getRegistry();
|
||||
const allHooks = registry.getAllHooks();
|
||||
|
||||
// Return enabled hooks for disable command (deduplicated by name)
|
||||
const enabledHookNames = allHooks
|
||||
.filter((hook) => hook.enabled)
|
||||
.map((hook) => hook.config.name || hook.config.command || '')
|
||||
.filter((name) => name && name.startsWith(partialArg));
|
||||
return [...new Set(enabledHookNames)];
|
||||
},
|
||||
};
|
||||
|
||||
export const hooksCommand: SlashCommand = {
|
||||
name: 'hooks',
|
||||
get description() {
|
||||
return t('Manage Qwen Code hooks');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, enableCommand, disableCommand],
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
// If no subcommand provided, show list
|
||||
if (!args.trim()) {
|
||||
const result = await listCommand.action?.(context, '');
|
||||
return result ?? { type: 'message', messageType: 'info', content: '' };
|
||||
}
|
||||
|
||||
const [subcommand, ...rest] = args.trim().split(/\s+/);
|
||||
const subArgs = rest.join(' ');
|
||||
|
||||
let result: SlashCommandActionReturn | void;
|
||||
switch (subcommand.toLowerCase()) {
|
||||
case 'list':
|
||||
result = await listCommand.action?.(context, subArgs);
|
||||
break;
|
||||
case 'enable':
|
||||
result = await enableCommand.action?.(context, subArgs);
|
||||
break;
|
||||
case 'disable':
|
||||
result = await disableCommand.action?.(context, subArgs);
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t(
|
||||
'Unknown subcommand: {{cmd}}. Available: list, enable, disable',
|
||||
{
|
||||
cmd: subcommand,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
return result ?? { type: 'message', messageType: 'info', content: '' };
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const subcommands = ['list', 'enable', 'disable'];
|
||||
const parts = partialArg.split(/\s+/);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
// Complete subcommand
|
||||
return subcommands.filter((cmd) => cmd.startsWith(partialArg));
|
||||
}
|
||||
|
||||
// Complete subcommand arguments
|
||||
const [subcommand, ...rest] = parts;
|
||||
const subArgs = rest.join(' ');
|
||||
|
||||
switch (subcommand.toLowerCase()) {
|
||||
case 'enable':
|
||||
return enableCommand.completion?.(context, subArgs) ?? [];
|
||||
case 'disable':
|
||||
return disableCommand.completion?.(context, subArgs) ?? [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -12,13 +12,8 @@ import {
|
|||
MCPDiscoveryState,
|
||||
getMCPServerStatus,
|
||||
getMCPDiscoveryState,
|
||||
DiscoveredMCPTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import { Type } from '@google/genai';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
|
|
@ -37,23 +32,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
// Helper function to create a mock DiscoveredMCPTool
|
||||
const createMockMCPTool = (
|
||||
name: string,
|
||||
serverName: string,
|
||||
description?: string,
|
||||
) =>
|
||||
new DiscoveredMCPTool(
|
||||
{
|
||||
callTool: vi.fn(),
|
||||
tool: vi.fn(),
|
||||
} as unknown as CallableTool,
|
||||
serverName,
|
||||
name,
|
||||
description || `Description for ${name}`,
|
||||
{ type: Type.OBJECT, properties: {} },
|
||||
);
|
||||
|
||||
describe('mcpCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
let mockConfig: {
|
||||
|
|
@ -70,7 +48,7 @@ describe('mcpCommand', () => {
|
|||
// Set up default mock environment
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
// Default mock implementations
|
||||
// Default mock implementations - these are kept for auth subcommand tests
|
||||
vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED);
|
||||
vi.mocked(getMCPDiscoveryState).mockReturnValue(
|
||||
MCPDiscoveryState.COMPLETED,
|
||||
|
|
@ -98,7 +76,16 @@ describe('mcpCommand', () => {
|
|||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should show an error if config is not available', async () => {
|
||||
it('should open MCP management dialog by default', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should open MCP management dialog even if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
|
|
@ -108,21 +95,19 @@ describe('mcpCommand', () => {
|
|||
const result = await mcpCommand.action!(contextWithoutConfig, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if tool registry is not available', async () => {
|
||||
it('should open MCP management dialog even if tool registry is not available', async () => {
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -138,73 +123,31 @@ describe('mcpCommand', () => {
|
|||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
});
|
||||
|
||||
it('should display configured MCP servers with status indicators and their tools', async () => {
|
||||
// Setup getMCPServerStatus mock implementation
|
||||
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
|
||||
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
|
||||
if (serverName === 'server2') return MCPServerStatus.CONNECTED;
|
||||
return MCPServerStatus.DISCONNECTED; // server3
|
||||
it('should open MCP management dialog regardless of server configuration', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
|
||||
// Mock tools from each server using actual DiscoveredMCPTool instances
|
||||
const mockServer1Tools = [
|
||||
createMockMCPTool('server1_tool1', 'server1'),
|
||||
createMockMCPTool('server1_tool2', 'server1'),
|
||||
];
|
||||
const mockServer2Tools = [createMockMCPTool('server2_tool1', 'server2')];
|
||||
const mockServer3Tools = [createMockMCPTool('server3_tool1', 'server3')];
|
||||
|
||||
const allTools = [
|
||||
...mockServer1Tools,
|
||||
...mockServer2Tools,
|
||||
...mockServer3Tools,
|
||||
];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(allTools),
|
||||
});
|
||||
|
||||
await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
tools: allTools.map((tool) => ({
|
||||
serverName: tool.serverName,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
schema: tool.schema,
|
||||
})),
|
||||
showTips: true,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display tool descriptions when desc argument is used', async () => {
|
||||
await mcpCommand.action!(mockContext, 'desc');
|
||||
it('should open MCP management dialog with desc argument', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'desc');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: true,
|
||||
showTips: false,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display descriptions when nodesc argument is used', async () => {
|
||||
await mcpCommand.action!(mockContext, 'nodesc');
|
||||
it('should open MCP management dialog with nodesc argument', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'nodesc');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: false,
|
||||
showTips: false,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,24 +6,17 @@
|
|||
|
||||
import type {
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
CommandContext,
|
||||
MessageActionReturn,
|
||||
OpenDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
DiscoveredMCPTool,
|
||||
getMCPDiscoveryState,
|
||||
getMCPServerStatus,
|
||||
MCPDiscoveryState,
|
||||
MCPServerStatus,
|
||||
getErrorMessage,
|
||||
MCPOAuthTokenStorage,
|
||||
MCPOAuthProvider,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
|
|
@ -189,183 +182,30 @@ const authCommand: SlashCommand = {
|
|||
},
|
||||
};
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
const manageCommand: SlashCommand = {
|
||||
name: 'manage',
|
||||
get description() {
|
||||
return t('List configured MCP servers and tools');
|
||||
return t('Open MCP management dialog');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | MessageActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Could not retrieve tool registry.'),
|
||||
};
|
||||
}
|
||||
|
||||
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
const hasDesc =
|
||||
lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions');
|
||||
const hasNodesc =
|
||||
lowerCaseArgs.includes('nodesc') ||
|
||||
lowerCaseArgs.includes('nodescriptions');
|
||||
const showSchema = lowerCaseArgs.includes('schema');
|
||||
|
||||
const showDescriptions = !hasNodesc && (hasDesc || showSchema);
|
||||
const showTips = lowerCaseArgs.length === 0;
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
const blockedMcpServers = config.getBlockedMcpServers() || [];
|
||||
|
||||
const connectingServers = serverNames.filter(
|
||||
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
||||
);
|
||||
const discoveryState = getMCPDiscoveryState();
|
||||
const discoveryInProgress =
|
||||
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
|
||||
connectingServers.length > 0;
|
||||
|
||||
const allTools = toolRegistry.getAllTools();
|
||||
const mcpTools = allTools.filter(
|
||||
(tool) => tool instanceof DiscoveredMCPTool,
|
||||
) as DiscoveredMCPTool[];
|
||||
|
||||
const promptRegistry = await config.getPromptRegistry();
|
||||
const mcpPrompts = promptRegistry
|
||||
.getAllPrompts()
|
||||
.filter(
|
||||
(prompt) =>
|
||||
'serverName' in prompt &&
|
||||
serverNames.includes(prompt.serverName as string),
|
||||
) as DiscoveredMCPPrompt[];
|
||||
|
||||
const authStatus: HistoryItemMcpStatus['authStatus'] = {};
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
for (const serverName of serverNames) {
|
||||
const server = mcpServers[serverName];
|
||||
if (server.oauth?.enabled) {
|
||||
const creds = await tokenStorage.getCredentials(serverName);
|
||||
if (creds) {
|
||||
if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) {
|
||||
authStatus[serverName] = 'expired';
|
||||
} else {
|
||||
authStatus[serverName] = 'authenticated';
|
||||
}
|
||||
} else {
|
||||
authStatus[serverName] = 'unauthenticated';
|
||||
}
|
||||
} else {
|
||||
authStatus[serverName] = 'not-configured';
|
||||
}
|
||||
}
|
||||
|
||||
const mcpStatusItem: HistoryItemMcpStatus = {
|
||||
type: MessageType.MCP_STATUS,
|
||||
servers: mcpServers,
|
||||
tools: mcpTools.map((tool) => ({
|
||||
serverName: tool.serverName,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
schema: tool.schema,
|
||||
})),
|
||||
prompts: mcpPrompts.map((prompt) => ({
|
||||
serverName: prompt.serverName as string,
|
||||
name: prompt.name,
|
||||
description: prompt.description,
|
||||
})),
|
||||
authStatus,
|
||||
blockedServers: blockedMcpServers,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
showDescriptions,
|
||||
showSchema,
|
||||
showTips,
|
||||
};
|
||||
|
||||
context.ui.addItem(mcpStatusItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
const refreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
get description() {
|
||||
return t('Restarts MCP servers.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Could not retrieve tool registry.'),
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t('Restarting MCP servers...'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
await toolRegistry.restartMcpServers();
|
||||
|
||||
// Update the client with the new tools
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
|
||||
// Reload the slash commands to reflect the changes.
|
||||
context.ui.reloadCommands();
|
||||
|
||||
return listCommand.action!(context, '');
|
||||
},
|
||||
action: async (): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
}),
|
||||
};
|
||||
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
get description() {
|
||||
return t(
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, authCommand, refreshCommand],
|
||||
// Default action when no subcommand is provided
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> =>
|
||||
// If no subcommand, run the list command
|
||||
listCommand.action!(context, args),
|
||||
subCommands: [manageCommand, authCommand],
|
||||
// Default action when no subcommand is provided - open dialog
|
||||
action: async (): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -152,7 +152,9 @@ export interface OpenDialogActionReturn {
|
|||
| 'subagent_list'
|
||||
| 'permissions'
|
||||
| 'approval-mode'
|
||||
| 'resume';
|
||||
| 'resume'
|
||||
| 'extensions_manage'
|
||||
| 'mcp';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -103,7 +103,9 @@ export const Composer = () => {
|
|||
)}
|
||||
|
||||
{/* Exclusive area: only one component visible at a time */}
|
||||
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
|
||||
{!showSuggestions &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
|
||||
(showShortcuts ? (
|
||||
<KeyboardShortcuts />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
|||
import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
|
||||
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
|
|
@ -339,6 +341,18 @@ export const DialogManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (uiState.isExtensionsManagerDialogOpen) {
|
||||
return (
|
||||
<ExtensionsManagerDialog
|
||||
onClose={uiActions.closeExtensionsManagerDialog}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isMcpDialogOpen) {
|
||||
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
|
||||
}
|
||||
|
||||
if (uiState.isResumeDialogOpen) {
|
||||
return (
|
||||
<SessionPicker
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue