Merge branch 'main' into feature/arena-agent-collaboration

This commit is contained in:
tanzhenxin 2026-03-09 11:13:31 +08:00
commit f9d4fa0a39
292 changed files with 28467 additions and 8155 deletions

View file

@ -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
View file

@ -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

View file

@ -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
#

View file

@ -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
View file

@ -24,6 +24,8 @@ package-lock.json
*.iml
.cursor
.qoder
.claude
CLAUDE.md
# OS metadata
.DS_Store

View file

@ -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

View 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}}

View 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)

View 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

View 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}

View file

@ -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
View file

@ -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
View 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
---

View file

@ -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';

View file

@ -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`)

View file

@ -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
```

View file

@ -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.

View file

@ -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

View file

@ -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 wasnt 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

View file

@ -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 dont 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.

View file

@ -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', {

View file

@ -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>

View 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
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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;

View 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],
});
});
});
});

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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;

View 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!"

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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
View file

@ -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"

View file

@ -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",

View file

@ -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"

View file

@ -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>;
}

View file

@ -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;
}
}

View file

@ -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'],
},
},
];
}

View file

@ -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;

View file

@ -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(

View file

@ -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);
}

View file

@ -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,
},
},
});

View file

@ -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' },

View 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}`);

View file

@ -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,

View file

@ -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 [

View file

@ -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);
}

View file

@ -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,
},

View file

@ -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 =

View file

@ -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,

View file

@ -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

View file

@ -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;
}

View 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.
},
};

View 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);
},
};

View 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);
},
};

View file

@ -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(

View file

@ -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

View 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);
});
});
});

View 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;
}

View 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);
});
});

View 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,
};
}
}

View 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[];
}

View 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',
];

View 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);
});
});
});

View 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();

View 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);
});
});
});

View 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();

View file

@ -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;

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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',

View file

@ -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', () => {

View file

@ -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);
}

View file

@ -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,
},
},
];
}

View file

@ -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,

View file

@ -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);

View file

@ -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',
};

View file

@ -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',
};

View file

@ -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: キャンセル',
};

View file

@ -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',
};

View file

@ -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: Отмена',
};

View file

@ -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: 取消',
};

View file

@ -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,

View file

@ -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"

View file

@ -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;
}

View file

@ -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', () => {

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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),
);
});
});
});

View file

@ -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),
};

View 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 [];
}
},
};

View file

@ -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',
});
});
});
});

View file

@ -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',
}),
};

View file

@ -152,7 +152,9 @@ export interface OpenDialogActionReturn {
| 'subagent_list'
| 'permissions'
| 'approval-mode'
| 'resume';
| 'resume'
| 'extensions_manage'
| 'mcp';
}
/**

View file

@ -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 />
) : (

View file

@ -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