Compare commits

..

10 Commits

Author SHA1 Message Date
pomelo-nwu
bfe8133ea3 feat: refactor docs 2025-12-05 10:51:57 +08:00
pomelo-nwu
17785c418d feat: restructure docs 2025-12-04 18:26:05 +08:00
tanzhenxin
6729980b47 skip acp integration test in sandbox env (#1141) 2025-12-04 10:28:21 +08:00
tanzhenxin
2ca36d7508 skip one flaky integration test (#1137) 2025-12-03 19:40:14 +08:00
tanzhenxin
e426c15e9e pump version to 0.4.0 (#1132) 2025-12-03 18:10:11 +08:00
tanzhenxin
0a75d85ac9 Session-Level Conversation History Management (#1113) 2025-12-03 18:04:48 +08:00
Zijun Yang
a7abd8d09f fix(shell-utils): resolve command detection on Ubuntu by using shell for builtins (#1123) 2025-12-02 11:49:40 +08:00
Mingholy
c9af74816a fix: reset authType settings (#1091)
* fix: reset authType settings

* fix: failed json-output tests

* fix: sandbox exception log to stderr
2025-11-23 17:59:35 +08:00
pomelo
9cfea73207 Merge pull request #1097 from QwenLM/chore-action
fix(ci): remove non-existent label from release failure issue creation
2025-11-23 08:27:34 +08:00
pomelo-nwu
87b1ffe017 fix(ci): remove non-existent label from release failure issue creation 2025-11-22 14:23:49 +08:00
205 changed files with 9420 additions and 4534 deletions

View File

@@ -224,5 +224,4 @@ jobs:
run: |-
gh issue create \
--title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}" \
--label "kind/bug,release-failure"
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}"

View File

@@ -1,10 +1,14 @@
export default {
index: 'Welcome to Qwen Code',
cli: 'CLI',
core: 'Core',
tools: 'Tools',
features: 'Features',
'ide-integration': 'IDE Integration',
development: 'Development',
support: 'Support',
index: {
type: 'page',
display: 'hidden',
},
users: {
type: 'page',
title: 'User Guide',
},
developers: {
type: 'page',
title: 'Developer Guide',
},
};

23
docs/developers/_meta.ts Normal file
View File

@@ -0,0 +1,23 @@
export default {
'Contribute to Qwen Code': {
title: 'Contribute to Qwen Code',
type: 'separator',
},
architecture: 'Architecture',
contributing: 'Contributing Guide',
roadmap: 'Roadmap',
'Qwen Code SDK': {
title: 'Qwen Code SDK',
type: 'separator',
},
'Dive Into Qwen Code': {
title: 'Dive Into Qwen Code',
type: 'separator',
},
cli: {
display: 'hidden',
},
core: 'Core',
tools: 'Tools',
// development: 'Development',
};

View File

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

View File

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 350 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 381 KiB

After

Width:  |  Height:  |  Size: 381 KiB

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View File

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 259 KiB

View File

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

View File

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -5,12 +5,7 @@ export default {
commands: 'Commands',
configuration: 'Configuration',
'configuration-v1': 'Configuration (v1)',
themes: 'Themes',
tutorials: 'Tutorials',
'keyboard-shortcuts': 'Keyboard Shortcuts',
'trusted-folders': 'Trusted Folders',
'qwen-ignore': 'Ignoring Files',
Uninstall: 'Uninstall',
};
/**

View File

@@ -11,31 +11,8 @@ Slash commands provide meta-level control over the CLI itself.
- **`/bug`**
- **Description:** File an issue about Qwen Code. By default, the issue is filed within the GitHub repository for Qwen Code. The string you enter after `/bug` will become the headline for the bug being filed. The default `/bug` behavior can be modified using the `advanced.bugCommand` setting in your `.qwen/settings.json` files.
- **`/chat`**
- **Description:** Save and resume conversation history for branching conversation state interactively, or resuming a previous state from a later session.
- **Sub-commands:**
- **`save`**
- **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state.
- **Usage:** `/chat save <tag>`
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
- Linux/macOS: `~/.qwen/tmp/<project_hash>/`
- Windows: `C:\Users\<YourUsername>\.qwen\tmp\<project_hash>\`
- When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints.
- **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md).
- **`resume`**
- **Description:** Resumes a conversation from a previous save.
- **Usage:** `/chat resume <tag>`
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
- **`share`**
- **Description** Writes the current conversation to a provided Markdown or JSON file.
- **Usage** `/chat share file.md` or `/chat share file.json`. If no filename is provided, then the CLI will generate one.
- **`/clear`**
- **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared.
- **`/clear`** (aliases: `reset`, `new`)
- **Description:** Clear conversation history and free up context by starting a fresh session. Also clears the terminal output and scrollback within the CLI.
- **Keyboard shortcut:** Press **Ctrl+L** at any time to perform a clear action.
- **`/summary`**

View File

@@ -671,4 +671,4 @@ Note: When usage statistics are enabled, events are sent to an Alibaba Cloud RUM
- **Category:** UI
- **Requires Restart:** No
- **Example:** `"enableWelcomeBack": false`
- **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/chat summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details.
- **Details:** When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. This feature integrates with the `/summary` command and quit confirmation dialog. See the [Welcome Back documentation](./welcome-back.md) for more details.

View File

@@ -548,6 +548,12 @@ Arguments passed directly when running the CLI can override other configurations
- The prompt is processed within the interactive session, not before it.
- Cannot be used when piping input from stdin.
- Example: `qwen -i "explain this code"`
- **`--continue`**:
- Resume the most recent session for the current project (current working directory).
- Works in interactive and headless modes (e.g., `qwen --continue -p "Keep going"`).
- **`--resume [sessionId]`**:
- Resume a specific session for the current project. When called without an ID, an interactive picker lists only this project's sessions with prompt preview, timestamps, message count, and optional git branch.
- If an ID is provided and not found for this project, the CLI exits with an error.
- **`--output-format <format>`** (**`-o <format>`**):
- **Description:** Specifies the format of the CLI output for non-interactive mode.
- **Values:**

View File

View File

@@ -1,7 +1,5 @@
export default {
architecture: 'Architecture',
npm: 'NPM',
deployment: 'Deployment',
telemetry: 'Telemetry',
'integration-tests': 'Integration Tests',
'issue-and-pr-automation': 'Issue and PR Automation',

View File

@@ -0,0 +1 @@
# Qwen Code RoadMap

View File

@@ -1,8 +0,0 @@
export default {
subagents: 'Subagents',
checkpointing: 'Checkpointing',
sandbox: 'Sandbox Support',
headless: 'Headless Mode',
'welcome-back': 'Welcome Back',
'token-caching': 'Token Caching',
};

View File

@@ -1,344 +0,0 @@
# Welcome to Qwen Code documentation
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 🚀 Why Choose Qwen Code?
- 🎯 **Free Tier:** Up to 60 requests/min and 2,000 requests/day with your [QwenChat](https://chat.qwen.ai/) account.
- 🧠 **Advanced Model:** Specially optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) for superior code understanding and assistance.
- 🏆 **Comprehensive Features:** Includes subagents, Plan Mode, TodoWrite, vision model support, and full OpenAI API compatibility—all seamlessly integrated.
- 🔧 **Built-in & Extensible Tools:** Includes file system operations, shell command execution, web fetch/search, and more—all easily extended via the Model Context Protocol (MCP) for custom integrations.
- 💻 **Developer-Centric:** Built for terminal-first workflows—perfect for command-line enthusiasts.
- 🛡️ **Open Source:** Apache 2.0 licensed for maximum freedom and transparency.
## Installation
### Prerequisites
Ensure you have [Node.js version 20](https://nodejs.org/en/download) or higher installed.
```bash
curl -qL https://www.npmjs.com/install.sh | sh
```
### Install from npm
```bash
npm install -g @qwen-code/qwen-code@latest
qwen --version
```
### Install from source
```bash
git clone https://github.com/QwenLM/qwen-code.git
cd qwen-code
npm install
npm install -g .
```
### Install globally with Homebrew (macOS/Linux)
```bash
brew install qwen-code
```
## Quick Start
```bash
# Start Qwen Code
qwen
# Example commands
> Explain this codebase structure
> Help me refactor this function
> Generate unit tests for this module
```
### Session Management
Control your token usage with configurable session limits to optimize costs and performance.
#### Configure Session Token Limit
Create or edit `.qwen/settings.json` in your home directory:
```json
{
"sessionTokenLimit": 32000
}
```
#### Session Commands
- **`/compress`** - Compress conversation history to continue within token limits
- **`/clear`** - Clear all conversation history and start fresh
- **`/stats`** - Check current token usage and limits
> 📝 **Note**: Session token limit applies to a single conversation, not cumulative API calls.
### Vision Model Configuration
Qwen Code includes intelligent vision model auto-switching that detects images in your input and can automatically switch to vision-capable models for multimodal analysis. **This feature is enabled by default** - when you include images in your queries, you'll see a dialog asking how you'd like to handle the vision model switch.
#### Skip the Switch Dialog (Optional)
If you don't want to see the interactive dialog each time, configure the default behavior in your `.qwen/settings.json`:
```json
{
"experimental": {
"vlmSwitchMode": "once"
}
}
```
**Available modes:**
- **`"once"`** - Switch to vision model for this query only, then revert
- **`"session"`** - Switch to vision model for the entire session
- **`"persist"`** - Continue with current model (no switching)
- **Not set** - Show interactive dialog each time (default)
#### Command Line Override
You can also set the behavior via command line:
```bash
# Switch once per query
qwen --vlm-switch-mode once
# Switch for entire session
qwen --vlm-switch-mode session
# Never switch automatically
qwen --vlm-switch-mode persist
```
#### Disable Vision Models (Optional)
To completely disable vision model support, add to your `.qwen/settings.json`:
```json
{
"experimental": {
"visionModelPreview": false
}
}
```
> 💡 **Tip**: In YOLO mode (`--yolo`), vision switching happens automatically without prompts when images are detected.
### Authorization
Choose your preferred authentication method based on your needs:
#### 1. Qwen OAuth (🚀 Recommended - Start in 30 seconds)
The easiest way to get started - completely free with generous quotas:
```bash
# Just run this command and follow the browser authentication
qwen
```
**What happens:**
1. **Instant Setup**: CLI opens your browser automatically
2. **One-Click Login**: Authenticate with your qwen.ai account
3. **Automatic Management**: Credentials cached locally for future use
4. **No Configuration**: Zero setup required - just start coding!
**Free Tier Benefits:**
-**2,000 requests/day** (no token counting needed)
-**60 requests/minute** rate limit
-**Automatic credential refresh**
-**Zero cost** for individual users
- **Note**: Model fallback may occur to maintain service quality
#### 2. OpenAI-Compatible API
Use API keys for OpenAI or other compatible providers:
**Configuration Methods:**
1. **Environment Variables**
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="your_api_endpoint"
export OPENAI_MODEL="your_model_choice"
```
2. **Project `.env` File**
Create a `.env` file in your project root:
```env
OPENAI_API_KEY=your_api_key_here
OPENAI_BASE_URL=your_api_endpoint
OPENAI_MODEL=your_model_choice
```
**API Provider Options**
> ⚠️ **Regional Notice:**
>
> - **Mainland China**: Use Alibaba Cloud Bailian or ModelScope
> - **International**: Use Alibaba Cloud ModelStudio or OpenRouter
<details>
<summary><b>🇨🇳 For Users in Mainland China</b></summary>
**Option 1: Alibaba Cloud Bailian** ([Apply for API Key](https://bailian.console.aliyun.com/))
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
export OPENAI_MODEL="qwen3-coder-plus"
```
**Option 2: ModelScope (Free Tier)** ([Apply for API Key](https://modelscope.cn/docs/model-service/API-Inference/intro))
- ✅ **2,000 free API calls per day**
- ⚠️ Connect your Aliyun account to avoid authentication errors
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="https://api-inference.modelscope.cn/v1"
export OPENAI_MODEL="Qwen/Qwen3-Coder-480B-A35B-Instruct"
```
</details>
<details>
<summary><b>🌍 For International Users</b></summary>
**Option 1: Alibaba Cloud ModelStudio** ([Apply for API Key](https://modelstudio.console.alibabacloud.com/))
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
export OPENAI_MODEL="qwen3-coder-plus"
```
**Option 2: OpenRouter (Free Tier Available)** ([Apply for API Key](https://openrouter.ai/))
```bash
export OPENAI_API_KEY="your_api_key_here"
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
export OPENAI_MODEL="qwen/qwen3-coder:free"
```
</details>
## Usage Examples
### 🔍 Explore Codebases
```bash
cd your-project/
qwen
# Architecture analysis
> Describe the main pieces of this system's architecture
> What are the key dependencies and how do they interact?
> Find all API endpoints and their authentication methods
```
### 💻 Code Development
```bash
# Refactoring
> Refactor this function to improve readability and performance
> Convert this class to use dependency injection
> Split this large module into smaller, focused components
# Code generation
> Create a REST API endpoint for user management
> Generate unit tests for the authentication module
> Add error handling to all database operations
```
### 🔄 Automate Workflows
```bash
# Git automation
> Analyze git commits from the last 7 days, grouped by feature
> Create a changelog from recent commits
> Find all TODO comments and create GitHub issues
# File operations
> Convert all images in this directory to PNG format
> Rename all test files to follow the *.test.ts pattern
> Find and remove all console.log statements
```
### 🐛 Debugging & Analysis
```bash
# Performance analysis
> Identify performance bottlenecks in this React component
> Find all N+1 query problems in the codebase
# Security audit
> Check for potential SQL injection vulnerabilities
> Find all hardcoded credentials or API keys
```
## Popular Tasks
### 📚 Understand New Codebases
```text
> What are the core business logic components?
> What security mechanisms are in place?
> How does the data flow through the system?
> What are the main design patterns used?
> Generate a dependency graph for this module
```
### 🔨 Code Refactoring & Optimization
```text
> What parts of this module can be optimized?
> Help me refactor this class to follow SOLID principles
> Add proper error handling and logging
> Convert callbacks to async/await pattern
> Implement caching for expensive operations
```
### 📝 Documentation & Testing
```text
> Generate comprehensive JSDoc comments for all public APIs
> Write unit tests with edge cases for this component
> Create API documentation in OpenAPI format
> Add inline comments explaining complex algorithms
> Generate a README for this module
```
### 🚀 Development Acceleration
```text
> Set up a new Express server with authentication
> Create a React component with TypeScript and tests
> Implement a rate limiter middleware
> Add database migrations for new schema
> Configure CI/CD pipeline for this project
```
## Commands & Shortcuts
### Session Commands
- `/help` - Display available commands
- `/clear` - Clear conversation history
- `/compress` - Compress history to save tokens
- `/stats` - Show current session information
- `/exit` or `/quit` - Exit Qwen Code
### Keyboard Shortcuts
- `Ctrl+C` - Cancel current operation
- `Ctrl+D` - Exit (on empty line)
- `Up/Down` - Navigate command history

View File

@@ -1,68 +0,0 @@
[
{
"label": "Overview",
"items": [
{ "label": "Welcome", "slug": "docs" },
{ "label": "Execution and Deployment", "slug": "docs/deployment" },
{ "label": "Architecture Overview", "slug": "docs/architecture" }
]
},
{
"label": "CLI",
"items": [
{ "label": "Introduction", "slug": "docs/cli" },
{ "label": "Authentication", "slug": "docs/cli/authentication" },
{ "label": "Commands", "slug": "docs/cli/commands" },
{ "label": "Configuration", "slug": "docs/cli/configuration" },
{ "label": "Checkpointing", "slug": "docs/checkpointing" },
{ "label": "Extensions", "slug": "docs/extension" },
{ "label": "Headless Mode", "slug": "docs/headless" },
{ "label": "IDE Integration", "slug": "docs/ide-integration" },
{
"label": "IDE Companion Spec",
"slug": "docs/ide-companion-spec"
},
{ "label": "Telemetry", "slug": "docs/telemetry" },
{ "label": "Themes", "slug": "docs/cli/themes" },
{ "label": "Token Caching", "slug": "docs/cli/token-caching" },
{ "label": "Trusted Folders", "slug": "docs/trusted-folders" },
{ "label": "Tutorials", "slug": "docs/cli/tutorials" }
]
},
{
"label": "Core",
"items": [
{ "label": "Introduction", "slug": "docs/core" },
{ "label": "Tools API", "slug": "docs/core/tools-api" },
{ "label": "Memory Import Processor", "slug": "docs/core/memport" }
]
},
{
"label": "Tools",
"items": [
{ "label": "Overview", "slug": "docs/tools" },
{ "label": "File System", "slug": "docs/tools/file-system" },
{ "label": "Multi-File Read", "slug": "docs/tools/multi-file" },
{ "label": "Shell", "slug": "docs/tools/shell" },
{ "label": "Web Fetch", "slug": "docs/tools/web-fetch" },
{ "label": "Web Search", "slug": "docs/tools/web-search" },
{ "label": "Memory", "slug": "docs/tools/memory" },
{ "label": "MCP Servers", "slug": "docs/tools/mcp-server" },
{ "label": "Sandboxing", "slug": "docs/sandbox" }
]
},
{
"label": "Development",
"items": [
{ "label": "NPM", "slug": "docs/npm" },
{ "label": "Releases", "slug": "docs/releases" }
]
},
{
"label": "Support",
"items": [
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
{ "label": "Terms of Service", "slug": "docs/tos-privacy" }
]
}
]

28
docs/users/_meta.ts Normal file
View File

@@ -0,0 +1,28 @@
export default {
'Getting started': {
type: 'separator',
title: 'Getting started', // Title is optional
},
overview: 'Overview',
'quick-start': 'QuickStart',
'common-workflow': 'Command Workflows',
'Outside of the terminal': {
type: 'separator',
title: 'Outside of the terminal', // Title is optional
},
'integration-github-action': 'Github Action',
'integration-vscode': 'VSCode Extension',
'integration-zed': 'Zed IDE',
'Code with Qwen Code': {
type: 'separator',
title: 'Code with Qwen Code', // Title is optional
},
features: 'Features',
configuration: 'Configuration',
reference: 'Reference',
support: 'Support',
// need refine
'ide-integration': {
display: 'hidden',
},
};

View File

View File

@@ -0,0 +1,7 @@
export default {
settings: 'Settings File',
memory: 'Memory Management',
'trusted-folders': 'Trusted Folders',
'qwen-ignore': 'Ignoring Files',
themes: 'Themes',
};

View File

View File

View File

@@ -0,0 +1,15 @@
export default {
subagents: 'Subagents',
'sub-commands': 'Sub Commands',
checkpointing: {
display: 'hidden',
},
headless: 'Headless Mode',
'welcome-back': {
display: 'hidden',
},
'approval-mode': 'Approval Mode',
'token-caching': 'Token Caching',
mcp: 'MCP',
sandbox: 'Sandboxing',
};

View File

View File

@@ -38,6 +38,7 @@ The headless mode provides a headless interface to Qwen Code that:
- Supports file redirection and piping
- Enables automation and scripting workflows
- Provides consistent exit codes for error handling
- Can resume previous sessions scoped to the current project for multi-step automation
## Basic Usage
@@ -65,6 +66,23 @@ Read from files and process with Qwen Code:
cat README.md | qwen --prompt "Summarize this documentation"
```
### Resume Previous Sessions (Headless)
Reuse conversation context from the current project in headless scripts:
```bash
# Continue the most recent session for this project and run a new prompt
qwen --continue -p "Run the tests again and summarize failures"
# Resume a specific session ID directly (no UI)
qwen --resume 123e4567-e89b-12d3-a456-426614174000 -p "Apply the follow-up refactor"
```
Notes:
- Session data is project-scoped JSONL under `~/.qwen/projects/<sanitized-cwd>/chats`.
- Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt.
## Output Formats
Qwen Code supports multiple output formats for different use cases:
@@ -196,17 +214,19 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq
Key command-line options for headless usage:
| Option | Description | Example |
| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ |
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
| Option | Description | Example |
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md).

View File

View File

View File

@@ -75,9 +75,9 @@ Add to your `.qwen/settings.json`:
### Project Summary Generation
The Welcome Back feature works seamlessly with the `/chat summary` command:
The Welcome Back feature works seamlessly with the `/summary` command:
1. **Generate Summary:** Use `/chat summary` to create a project summary
1. **Generate Summary:** Use `/summary` to create a project summary
2. **Automatic Detection:** Next time you start Qwen Code in this project, Welcome Back will detect the summary
3. **Resume Work:** Choose to continue and the summary will be loaded as context

View File

View File

View File

0
docs/users/overview.md Normal file
View File

View File

View File

@@ -0,0 +1,3 @@
export default {
'keyboard-shortcuts': 'Keyboard Shortcuts',
};

View File

@@ -1,4 +1,6 @@
export default {
troubleshooting: 'Troubleshooting',
'tos-privacy': 'Terms of Service',
Uninstall: 'Uninstall',
};

View File

@@ -0,0 +1,590 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { setTimeout as delay } from 'node:timers/promises';
import { describe, expect, it } from 'vitest';
import { TestRig } from './test-helper.js';
const REQUEST_TIMEOUT_MS = 60_000;
const INITIAL_PROMPT = 'Create a quick note (smoke test).';
const RESUME_PROMPT = 'Continue the note after reload.';
const LIST_SIZE = 5;
const IS_SANDBOX =
process.env['GEMINI_SANDBOX'] &&
process.env['GEMINI_SANDBOX']!.toLowerCase() !== 'false';
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timeout: NodeJS.Timeout;
};
type SessionUpdateNotification = {
sessionId?: string;
update?: {
sessionUpdate?: string;
availableCommands?: Array<{
name: string;
description: string;
input?: { hint: string } | null;
}>;
content?: {
type: string;
text?: string;
};
modeId?: string;
};
};
type PermissionRequest = {
id: number;
sessionId?: string;
toolCall?: {
toolCallId: string;
title: string;
kind: string;
status: string;
content?: Array<{
type: string;
text?: string;
path?: string;
oldText?: string;
newText?: string;
}>;
};
options?: Array<{
optionId: string;
name: string;
kind: string;
}>;
};
type PermissionHandler = (
request: PermissionRequest,
) => { optionId: string } | { outcome: 'cancelled' };
/**
* Sets up an ACP test environment with all necessary utilities.
*/
function setupAcpTest(
rig: TestRig,
options?: { permissionHandler?: PermissionHandler },
) {
const pending = new Map<number, PendingRequest>();
let nextRequestId = 1;
const sessionUpdates: SessionUpdateNotification[] = [];
const permissionRequests: PermissionRequest[] = [];
const stderr: string[] = [];
// Default permission handler: auto-approve all
const permissionHandler =
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
const agent = spawn('node', [rig.bundlePath, '--experimental-acp'], {
cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'],
});
agent.stderr?.on('data', (chunk) => {
stderr.push(chunk.toString());
});
const rl = createInterface({ input: agent.stdout });
const send = (json: unknown) => {
agent.stdin.write(`${JSON.stringify(json)}\n`);
};
const sendResponse = (id: number, result: unknown) => {
send({ jsonrpc: '2.0', id, result });
};
const sendRequest = (method: string, params?: unknown) =>
new Promise<unknown>((resolve, reject) => {
const id = nextRequestId++;
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(`Request ${id} (${method}) timed out`));
}, REQUEST_TIMEOUT_MS);
pending.set(id, { resolve, reject, timeout });
send({ jsonrpc: '2.0', id, method, params });
});
const handleResponse = (msg: {
id: number;
result?: unknown;
error?: { message?: string };
}) => {
const waiter = pending.get(msg.id);
if (!waiter) {
return;
}
clearTimeout(waiter.timeout);
pending.delete(msg.id);
if (msg.error) {
waiter.reject(new Error(msg.error.message ?? 'Unknown error'));
} else {
waiter.resolve(msg.result);
}
};
const handleMessage = (msg: {
id?: number;
method?: string;
params?: SessionUpdateNotification & {
path?: string;
content?: string;
sessionId?: string;
toolCall?: PermissionRequest['toolCall'];
options?: PermissionRequest['options'];
};
result?: unknown;
error?: { message?: string };
}) => {
if (typeof msg.id !== 'undefined' && ('result' in msg || 'error' in msg)) {
handleResponse(
msg as {
id: number;
result?: unknown;
error?: { message?: string };
},
);
return;
}
if (msg.method === 'session/update') {
sessionUpdates.push({
sessionId: msg.params?.sessionId,
update: msg.params?.update,
});
return;
}
if (
msg.method === 'session/request_permission' &&
typeof msg.id === 'number'
) {
// Track permission request
const permRequest: PermissionRequest = {
id: msg.id,
sessionId: msg.params?.sessionId,
toolCall: msg.params?.toolCall,
options: msg.params?.options,
};
permissionRequests.push(permRequest);
// Use custom handler or default
const response = permissionHandler(permRequest);
if ('outcome' in response) {
sendResponse(msg.id, { outcome: response });
} else {
sendResponse(msg.id, {
outcome: { optionId: response.optionId, outcome: 'selected' },
});
}
return;
}
if (msg.method === 'fs/read_text_file' && typeof msg.id === 'number') {
try {
const content = readFileSync(msg.params?.path ?? '', 'utf8');
sendResponse(msg.id, { content });
} catch (e) {
sendResponse(msg.id, { content: `ERROR: ${(e as Error).message}` });
}
return;
}
if (msg.method === 'fs/write_text_file' && typeof msg.id === 'number') {
try {
writeFileSync(
msg.params?.path ?? '',
msg.params?.content ?? '',
'utf8',
);
sendResponse(msg.id, null);
} catch (e) {
sendResponse(msg.id, { message: (e as Error).message });
}
}
};
rl.on('line', (line) => {
if (!line.trim()) return;
try {
const msg = JSON.parse(line);
handleMessage(msg);
} catch {
// Ignore non-JSON output from the agent.
}
});
const waitForExit = () =>
new Promise<void>((resolve) => {
if (agent.exitCode !== null || agent.signalCode) {
resolve();
return;
}
agent.once('exit', () => resolve());
});
const cleanup = async () => {
rl.close();
agent.kill();
pending.forEach(({ timeout }) => clearTimeout(timeout));
pending.clear();
await waitForExit();
};
return {
sendRequest,
sendResponse,
cleanup,
stderr,
sessionUpdates,
permissionRequests,
};
}
(IS_SANDBOX ? describe.skip : describe)('acp integration', () => {
it('creates, lists, loads, and resumes a session', async () => {
const rig = new TestRig();
rig.setup('acp load session');
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
try {
const initResult = await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
expect(initResult).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((initResult as any).agentInfo.version).toBeDefined();
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: INITIAL_PROMPT }],
});
expect(promptResult).toBeDefined();
await delay(500);
const listResult = (await sendRequest('session/list', {
cwd: rig.testDir!,
size: LIST_SIZE,
})) as { items?: Array<{ sessionId: string }> };
expect(Array.isArray(listResult.items)).toBe(true);
expect(listResult.items?.length ?? 0).toBeGreaterThan(0);
const sessionToLoad = listResult.items![0].sessionId;
await sendRequest('session/load', {
cwd: rig.testDir!,
sessionId: sessionToLoad,
mcpServers: [],
});
const resumeResult = await sendRequest('session/prompt', {
sessionId: sessionToLoad,
prompt: [{ type: 'text', text: RESUME_PROMPT }],
});
expect(resumeResult).toBeDefined();
const sessionsWithUpdates = sessionUpdates
.map((update) => update.sessionId)
.filter(Boolean);
expect(sessionsWithUpdates).toContain(sessionToLoad);
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
it('returns modes on initialize and allows setting approval mode', async () => {
const rig = new TestRig();
rig.setup('acp approval mode');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
try {
// Test 1: Initialize and verify modes are returned
const initResult = (await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
})) as {
protocolVersion: number;
modes: {
currentModeId: string;
availableModes: Array<{
id: string;
name: string;
description: string;
}>;
};
};
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' });
// Test 3: Create a new session
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Test 4: Set approval mode to 'yolo'
const setModeResult = (await sendRequest('session/set_mode', {
sessionId: newSession.sessionId,
modeId: 'yolo',
})) as { modeId: string };
expect(setModeResult).toBeDefined();
expect(setModeResult.modeId).toBe('yolo');
// 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');
// 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');
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
it('receives available_commands_update with slash commands after session creation', async () => {
const rig = new TestRig();
rig.setup('acp slash commands');
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
try {
// Initialize
await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
await sendRequest('authenticate', { methodId: 'openai' });
// Create a new session
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Wait for available_commands_update to be received
await delay(1000);
// Verify available_commands_update is received
const commandsUpdate = sessionUpdates.find(
(update) =>
update.update?.sessionUpdate === 'available_commands_update',
);
expect(commandsUpdate).toBeDefined();
expect(commandsUpdate?.update?.availableCommands).toBeDefined();
expect(Array.isArray(commandsUpdate?.update?.availableCommands)).toBe(
true,
);
// Verify that the 'init' command is present (the only allowed built-in command for ACP)
const initCommand = commandsUpdate?.update?.availableCommands?.find(
(cmd) => cmd.name === 'init',
);
expect(initCommand).toBeDefined();
expect(initCommand?.description).toBeTruthy();
// Note: We don't test /init execution here because it triggers a complex
// multi-step process (listing files, reading up to 10 files, generating QWEN.md)
// that can take 30-60+ seconds, exceeding the request timeout.
// The slash command execution path is tested via simpler prompts in other tests.
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
it('handles exit plan mode with permission request and mode update notification', async () => {
const rig = new TestRig();
rig.setup('acp exit plan mode');
// Track which permission requests we've seen
const planModeRequests: PermissionRequest[] = [];
const { sendRequest, cleanup, stderr, sessionUpdates, permissionRequests } =
setupAcpTest(rig, {
permissionHandler: (request) => {
// Track all permission requests for later verification
// Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode
if (request.toolCall?.kind === 'switch_mode') {
planModeRequests.push(request);
// Return proceed_always to switch to auto-edit mode
return { optionId: 'proceed_always' };
}
// Auto-approve all other requests
return { optionId: 'proceed_once' };
},
});
try {
// Initialize
await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
await sendRequest('authenticate', { methodId: 'openai' });
// Create a new session
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Set mode to 'plan' to enable plan mode
const setModeResult = (await sendRequest('session/set_mode', {
sessionId: newSession.sessionId,
modeId: 'plan',
})) as { modeId: string };
expect(setModeResult.modeId).toBe('plan');
// Send a prompt that should trigger the LLM to call exit_plan_mode
// The prompt is designed to trigger planning behavior
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [
{
type: 'text',
text: 'Create a simple hello world function in Python. Make a brief plan and when ready, use the exit_plan_mode tool to present it for approval.',
},
],
});
expect(promptResult).toBeDefined();
// Give time for all notifications to be processed
await delay(1000);
// Verify: If exit_plan_mode was called, we should have received:
// 1. A permission request with kind: "switch_mode"
// 2. A current_mode_update notification after approval
// Check for switch_mode permission requests
const switchModeRequests = permissionRequests.filter(
(req) => req.toolCall?.kind === 'switch_mode',
);
// Check for current_mode_update notifications
const modeUpdateNotifications = sessionUpdates.filter(
(update) => update.update?.sessionUpdate === 'current_mode_update',
);
// If the LLM called exit_plan_mode, verify the flow
if (switchModeRequests.length > 0) {
// Verify permission request structure
const permReq = switchModeRequests[0];
expect(permReq.toolCall).toBeDefined();
expect(permReq.toolCall?.kind).toBe('switch_mode');
expect(permReq.toolCall?.status).toBe('pending');
expect(permReq.options).toBeDefined();
expect(Array.isArray(permReq.options)).toBe(true);
// Verify options include appropriate choices
const optionKinds = permReq.options?.map((opt) => opt.kind) ?? [];
expect(optionKinds).toContain('allow_once');
expect(optionKinds).toContain('allow_always');
// After approval, should have received current_mode_update
expect(modeUpdateNotifications.length).toBeGreaterThan(0);
// Verify mode update structure
const modeUpdate = modeUpdateNotifications[0];
expect(modeUpdate.sessionId).toBe(newSession.sessionId);
expect(modeUpdate.update?.modeId).toBeDefined();
// Mode should be auto-edit since we approved with proceed_always
expect(modeUpdate.update?.modeId).toBe('auto-edit');
}
// Note: If the LLM didn't call exit_plan_mode, that's acceptable
// since LLM behavior is non-deterministic. The test setup and structure
// is verified regardless.
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
});

View File

@@ -6,124 +6,71 @@
import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('Ctrl+C exit', () => {
// (#9782) Temporarily disabling on windows because it is failing on main and every
// PR, which is potentially hiding other failures
it.skipIf(process.platform === 'win32')(
'should exit gracefully on second Ctrl+C',
async () => {
const rig = new TestRig();
await rig.setup('should exit gracefully on second Ctrl+C');
it.skip('should exit gracefully on second Ctrl+C', async () => {
const rig = new TestRig();
await rig.setup('should exit gracefully on second Ctrl+C');
const { ptyProcess, promise } = rig.runInteractive();
const { ptyProcess, promise } = rig.runInteractive();
let output = '';
ptyProcess.onData((data) => {
output += data;
});
let output = '';
ptyProcess.onData((data) => {
output += data;
});
// Wait for the app to be ready by looking for the initial prompt indicator
await rig.poll(() => output.includes('▶'), 5000, 100);
const isReady = await rig.waitForText('Type your message', 15000);
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
true,
);
// Send first Ctrl+C
ptyProcess.write(String.fromCharCode(3));
// Send first Ctrl+C
ptyProcess.write(String.fromCharCode(3));
// Wait for the exit prompt
await rig.poll(
() => output.includes('Press Ctrl+C again to exit'),
1500,
50,
);
// Wait for the exit prompt
const showedExitPrompt = await rig.poll(
() => output.includes('Press Ctrl+C again to exit'),
1500,
50,
);
expect(showedExitPrompt, `Exit prompt not shown. Output: ${output}`).toBe(
true,
);
// Send second Ctrl+C
ptyProcess.write(String.fromCharCode(3));
// Send second Ctrl+C
ptyProcess.write(String.fromCharCode(3));
const result = await promise;
// Wait for process exit with timeout to fail fast
const EXIT_TIMEOUT = 5000;
const result = await Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
`Process did not exit within ${EXIT_TIMEOUT}ms. Output: ${output}`,
),
),
EXIT_TIMEOUT,
),
),
]);
// Expect a graceful exit (code 0)
expect(
result.exitCode,
`Process exited with code ${result.exitCode}. Output: ${result.output}`,
).toBe(0);
// Expect a graceful exit (code 0)
expect(
result.exitCode,
`Process exited with code ${result.exitCode}. Output: ${result.output}`,
).toBe(0);
// Check that the quitting message is displayed
const quittingMessage = 'Agent powering down. Goodbye!';
// The regex below is intentionally matching the ESC control character (\x1b)
// to strip ANSI color codes from the terminal output.
// eslint-disable-next-line no-control-regex
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
expect(cleanOutput).toContain(quittingMessage);
},
);
it.skipIf(process.platform === 'win32')(
'should exit gracefully on second Ctrl+C when calling a tool',
async () => {
const rig = new TestRig();
await rig.setup(
'should exit gracefully on second Ctrl+C when calling a tool',
);
const childProcessFile = 'child_process_file.txt';
rig.createFile(
'wait.js',
`setTimeout(() => require('fs').writeFileSync('${childProcessFile}', 'done'), 5000)`,
);
const { ptyProcess, promise } = rig.runInteractive();
let output = '';
ptyProcess.onData((data) => {
output += data;
});
// Wait for the app to be ready by looking for the initial prompt indicator
await rig.poll(() => output.includes('▶'), 5000, 100);
ptyProcess.write('use the tool to run "node -e wait.js"\n');
await rig.poll(() => output.includes('Shell'), 5000, 100);
// Send first Ctrl+C
ptyProcess.write(String.fromCharCode(3));
// Wait for the exit prompt
await rig.poll(
() => output.includes('Press Ctrl+C again to exit'),
1500,
50,
);
// Send second Ctrl+C
ptyProcess.write(String.fromCharCode(3));
const result = await promise;
// Expect a graceful exit (code 0)
expect(
result.exitCode,
`Process exited with code ${result.exitCode}. Output: ${result.output}`,
).toBe(0);
// Check that the quitting message is displayed
const quittingMessage = 'Agent powering down. Goodbye!';
// The regex below is intentionally matching the ESC control character (\x1b)
// to strip ANSI color codes from the terminal output.
// eslint-disable-next-line no-control-regex
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
expect(cleanOutput).toContain(quittingMessage);
// Check that the child process was terminated and did not create the file.
const childProcessFileExists = fs.existsSync(
path.join(rig.testDir!, childProcessFile),
);
expect(
childProcessFileExists,
'Child process file should not exist',
).toBe(false);
},
);
// Check that the quitting message is displayed
const quittingMessage = 'Agent powering down. Goodbye!';
// The regex below is intentionally matching the ESC control character (\x1b)
// to strip ANSI color codes from the terminal output.
// eslint-disable-next-line no-control-regex
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
expect(cleanOutput).toContain(quittingMessage);
});
});

View File

@@ -53,82 +53,6 @@ describe('JSON output', () => {
}
});
it('should return a JSON error for enforced auth mismatch before running', async () => {
const originalOpenaiApiKey = process.env['OPENAI_API_KEY'];
process.env['OPENAI_API_KEY'] = 'test-key';
await rig.setup('json-output-auth-mismatch', {
settings: {
security: { auth: { enforcedType: 'qwen-oauth' } },
},
});
let thrown: Error | undefined;
try {
await rig.run('Hello', '--output-format', 'json');
expect.fail('Expected process to exit with error');
} catch (e) {
thrown = e as Error;
} finally {
process.env['OPENAI_API_KEY'] = originalOpenaiApiKey;
}
expect(thrown).toBeDefined();
const message = (thrown as Error).message;
// The error JSON is written to stdout as a CLIResultMessageError
// Extract stdout from the error message
const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/);
expect(
stdoutMatch,
'Expected to find stdout in the error message',
).toBeTruthy();
const stdout = stdoutMatch![1];
let parsed: unknown[];
try {
// Parse the JSON array from stdout
parsed = JSON.parse(stdout);
} catch (parseError) {
console.error('Failed to parse the following JSON:', stdout);
throw new Error(
`Test failed: Could not parse JSON from stdout. Details: ${parseError}`,
);
}
// The output should be an array of messages
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBeGreaterThan(0);
// Find the result message with error
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result' &&
'is_error' in msg &&
msg.is_error === true,
) as {
type: string;
is_error: boolean;
subtype: string;
error?: { message: string; type?: string };
};
expect(resultMessage).toBeDefined();
expect(resultMessage.is_error).toBe(true);
expect(resultMessage).toHaveProperty('subtype');
expect(resultMessage.subtype).toBe('error_during_execution');
expect(resultMessage).toHaveProperty('error');
expect(resultMessage.error).toBeDefined();
expect(resultMessage.error?.message).toContain(
'configured auth type is qwen-oauth',
);
expect(resultMessage.error?.message).toContain(
'current auth type is openai',
);
});
it('should return line-delimited JSON messages for stream-json output format', async () => {
const result = await rig.run(
'What is the capital of France?',
@@ -307,4 +231,80 @@ describe('JSON output', () => {
expect(resultMessage).toHaveProperty('result');
expect(resultMessage.result.toLowerCase()).toContain('paris');
});
it('should return a JSON error for enforced auth mismatch before running', async () => {
const originalOpenaiApiKey = process.env['OPENAI_API_KEY'];
process.env['OPENAI_API_KEY'] = 'test-key';
await rig.setup('json-output-auth-mismatch', {
settings: {
security: { auth: { enforcedType: 'qwen-oauth' } },
},
});
let thrown: Error | undefined;
try {
await rig.run('Hello', '--output-format', 'json');
expect.fail('Expected process to exit with error');
} catch (e) {
thrown = e as Error;
} finally {
process.env['OPENAI_API_KEY'] = originalOpenaiApiKey;
}
expect(thrown).toBeDefined();
const message = (thrown as Error).message;
// The error JSON is written to stdout as a CLIResultMessageError
// Extract stdout from the error message
const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/);
expect(
stdoutMatch,
'Expected to find stdout in the error message',
).toBeTruthy();
const stdout = stdoutMatch![1];
let parsed: unknown[];
try {
// Parse the JSON array from stdout
parsed = JSON.parse(stdout);
} catch (parseError) {
console.error('Failed to parse the following JSON:', stdout);
throw new Error(
`Test failed: Could not parse JSON from stdout. Details: ${parseError}`,
);
}
// The output should be an array of messages
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBeGreaterThan(0);
// Find the result message with error
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result' &&
'is_error' in msg &&
msg.is_error === true,
) as {
type: string;
is_error: boolean;
subtype: string;
error?: { message: string; type?: string };
};
expect(resultMessage).toBeDefined();
expect(resultMessage.is_error).toBe(true);
expect(resultMessage).toHaveProperty('subtype');
expect(resultMessage.subtype).toBe('error_during_execution');
expect(resultMessage).toHaveProperty('error');
expect(resultMessage.error).toBeDefined();
expect(resultMessage.error?.message).toContain(
'configured auth type is qwen-oauth',
);
expect(resultMessage.error?.message).toContain(
'current auth type is openai',
);
});
});

23
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0",
"workspaces": [
"packages/*"
],
@@ -5255,6 +5255,15 @@
"node": ">= 0.4"
}
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -14687,7 +14696,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
@@ -16024,7 +16032,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -16139,7 +16147,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.3.0",
"version": "0.4.0",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -16158,6 +16166,7 @@
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"async-mutex": "^0.5.0",
"chardet": "^2.1.0",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
@@ -16278,7 +16287,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.3.0",
"version": "0.4.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -16290,7 +16299,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.3.0",
"version": "0.4.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.3.0",
"version": "0.4.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0"
},
"dependencies": {
"@google/genai": "1.16.0",

View File

@@ -42,6 +42,14 @@ export class AgentSideConnection implements Client {
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);
@@ -55,6 +63,13 @@ export class AgentSideConnection implements Client {
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);
}
default:
throw RequestError.methodNotFound(method);
}
@@ -360,7 +375,11 @@ export interface Agent {
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>;
}

View File

@@ -0,0 +1,329 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { ReadableStream, WritableStream } from 'node:stream/web';
import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core';
import {
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
clearCachedCredentialFile,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
import { AcpFileSystemService } from './service/filesystem.js';
import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import { z } from 'zod';
import { ExtensionStorage, type Extension } from '../config/extension.js';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js';
// Import the modular Session class
import { Session } from './session/Session.js';
export async function runAcpAgent(
config: Config,
settings: LoadedSettings,
extensions: Extension[],
argv: CliArgs,
) {
const stdout = Writable.toWeb(process.stdout) as WritableStream;
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
// Stdout is used to send messages to the client, so console.log/console.info
// messages to stderr so that they don't interfere with ACP.
console.log = console.error;
console.info = console.error;
console.debug = console.error;
new acp.AgentSideConnection(
(client: acp.Client) =>
new GeminiAgent(config, settings, extensions, argv, client),
stdout,
stdin,
);
}
class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
constructor(
private config: Config,
private settings: LoadedSettings,
private extensions: Extension[],
private argv: CliArgs,
private client: acp.Client,
) {}
async initialize(
args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
this.clientCapabilities = args.clientCapabilities;
const authMethods = [
{
id: AuthType.USE_OPENAI,
name: 'Use OpenAI API key',
description:
'Requires setting the `OPENAI_API_KEY` environment variable',
},
{
id: AuthType.QWEN_OAUTH,
name: 'Qwen OAuth',
description:
'OAuth authentication for Qwen models with 2000 daily requests',
},
];
// 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 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,
agentInfo: {
name: 'qwen-code',
title: 'Qwen Code',
version,
},
authMethods,
modes: {
currentModeId: currentApprovalMode as ApprovalModeValue,
availableModes,
},
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: true,
embeddedContext: true,
},
},
};
}
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
await clearCachedCredentialFile();
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
}
async newSession({
cwd,
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const config = await this.newSessionConfig(cwd, mcpServers);
await this.ensureAuthenticated(config);
this.setupFileSystem(config);
const session = await this.createAndStoreSession(config);
return {
sessionId: session.getId(),
};
}
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,
this.extensions,
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
this.argv.extensions,
),
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> {
const sessionService = new SessionService(params.cwd);
const exists = await sessionService.sessionExists(params.sessionId);
if (!exists) {
throw acp.RequestError.invalidParams(
`Session not found for id: ${params.sessionId}`,
);
}
const config = await this.newSessionConfig(
params.cwd,
params.mcpServers,
params.sessionId,
);
await this.ensureAuthenticated(config);
this.setupFileSystem(config);
const sessionData = config.getResumedSessionData();
if (!sessionData) {
throw acp.RequestError.internalError(
`Failed to load session data for id: ${params.sessionId}`,
);
}
await this.createAndStoreSession(config, sessionData.conversation);
return null;
}
async listSessions(
params: acp.ListSessionsRequest,
): Promise<acp.ListSessionsResponse> {
const sessionService = new SessionService(params.cwd);
const result = await sessionService.listSessions({
cursor: params.cursor,
size: params.size,
});
return {
items: result.items.map((item) => ({
sessionId: item.sessionId,
cwd: item.cwd,
startTime: item.startTime,
mtime: item.mtime,
prompt: item.prompt,
gitBranch: item.gitBranch,
filePath: item.filePath,
messageCount: item.messageCount,
})),
nextCursor: result.nextCursor,
hasMore: result.hasMore,
};
}
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.setMode(params);
}
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
if (!selectedType) {
throw acp.RequestError.authRequired();
}
try {
await config.refreshAuth(selectedType);
} catch (e) {
console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
}
}
private setupFileSystem(config: Config): void {
if (!this.clientCapabilities?.fs) {
return;
}
const acpFileSystemService = new AcpFileSystemService(
this.client,
config.getSessionId(),
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
private async createAndStoreSession(
config: Config,
conversation?: ConversationRecord,
): Promise<Session> {
const sessionId = config.getSessionId();
const geminiClient = config.getGeminiClient();
const history = conversation
? buildApiHistoryFromConversation(conversation)
: undefined;
const chat = history
? await geminiClient.startChat(history)
: await geminiClient.startChat();
const session = new Session(
sessionId,
chat,
config,
this.client,
this.settings,
);
this.sessions.set(sessionId, session);
setTimeout(async () => {
await session.sendAvailableCommandsUpdate();
}, 0);
if (conversation && conversation.messages) {
await session.replayHistory(conversation.messages);
}
return session;
}
}

Some files were not shown because too many files have changed in this diff Show More