Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
29d3424d4b chore(release): v0.4.0-preview.1 2025-12-05 15:26:09 +00:00
214 changed files with 679 additions and 25396 deletions

2
.vscode/launch.json vendored
View File

@@ -27,7 +27,7 @@
"outFiles": [
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
],
"preLaunchTask": "launch: vscode-ide-companion (copy+build)"
"preLaunchTask": "npm: build: vscode-ide-companion"
},
{
"name": "Attach",

16
.vscode/tasks.json vendored
View File

@@ -20,22 +20,6 @@
"problemMatcher": [],
"label": "npm: build: vscode-ide-companion",
"detail": "npm run build -w packages/vscode-ide-companion"
},
{
"label": "copy: bundled-cli (dev)",
"type": "shell",
"command": "node",
"args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"],
"problemMatcher": []
},
{
"label": "launch: vscode-ide-companion (copy+build)",
"dependsOrder": "sequence",
"dependsOn": [
"copy: bundled-cli (dev)",
"npm: build: vscode-ide-companion"
],
"problemMatcher": []
}
]
}

View File

@@ -88,12 +88,6 @@ npm install -g .
brew install qwen-code
```
## VS Code Extension
In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more.
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
## Quick Start
```bash

View File

@@ -10,21 +10,19 @@ The `/language` command allows you to customize the language settings for both t
To change the UI language of Qwen Code, use the `ui` subcommand:
```
/language ui [zh-CN|en-US|ru-RU]
/language ui [zh-CN|en-US]
```
### Available UI Languages
- **zh-CN**: Simplified Chinese (简体中文)
- **en-US**: English
- **ru-RU**: Russian (Русский)
### Examples
```
/language ui zh-CN # Set UI language to Simplified Chinese
/language ui en-US # Set UI language to English
/language ui ru-RU # Set UI language to Russian
```
### UI Language Subcommands
@@ -33,7 +31,6 @@ You can also use direct subcommands for convenience:
- `/language ui zh-CN` or `/language ui zh` or `/language ui 中文`
- `/language ui en-US` or `/language ui en` or `/language ui english`
- `/language ui ru-RU` or `/language ui ru` or `/language ui русский`
## LLM Output Language Settings

View File

@@ -1,534 +0,0 @@
# MCP Example Configurations
Ready-to-use MCP server configurations for common scenarios.
## 📁 Local Development
### Basic Setup
```json
{
"mcpServers": {
"workspace": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"trust": true,
"description": "Full workspace access"
}
}
}
```
### Multi-Directory Project
```json
{
"mcpServers": {
"frontend": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"./src",
"./public",
"./tests"
],
"trust": true,
"description": "Frontend development files"
},
"config": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"./config",
"./.env.example"
],
"trust": true,
"includeTools": ["read_file", "list_directory"],
"description": "Configuration files (read-only)"
}
}
}
```
## 🧠 Memory & Context
### Persistent Memory
```json
{
"mcpServers": {
"project-memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": true,
"description": "Remember project context across sessions"
}
}
}
```
### Combined with Filesystem
```json
{
"mcpServers": {
"files": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"trust": true
},
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": true
}
}
}
```
## 🌐 Remote Servers (HTTP/SSE)
### HTTP MCP Server
```json
{
"mcpServers": {
"remote-api": {
"httpUrl": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer ${API_TOKEN}",
"Content-Type": "application/json"
},
"timeout": 30000,
"description": "Remote MCP API"
}
}
}
```
### SSE Server with OAuth
```json
{
"mcpServers": {
"sse-service": {
"url": "https://mcp.example.com/sse",
"oauth": {
"enabled": true,
"scopes": ["read", "write"]
},
"timeout": 60000,
"description": "SSE server with OAuth"
}
}
}
```
## 🐍 Python MCP Servers
### Simple Python Server
```json
{
"mcpServers": {
"python-tools": {
"command": "python",
"args": ["-m", "my_mcp_server"],
"env": {
"PYTHONPATH": "${PWD}",
"DEBUG": "false"
},
"description": "Custom Python MCP tools"
}
}
}
```
### Python with Virtual Environment
```json
{
"mcpServers": {
"python-venv": {
"command": "./venv/bin/python",
"args": ["-m", "mcp_server"],
"cwd": "./",
"env": {
"VIRTUAL_ENV": "${PWD}/venv"
},
"description": "Python server in virtual environment"
}
}
}
```
## 🐳 Docker Containers
### Basic Docker Server
```json
{
"mcpServers": {
"docker-mcp": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"my-mcp-server:latest"
],
"timeout": 45000,
"description": "MCP server in Docker"
}
}
}
```
### Docker with Volume Mounts
```json
{
"mcpServers": {
"docker-workspace": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-v",
"${PWD}:/workspace",
"-w",
"/workspace",
"-e",
"API_KEY",
"mcp-tools:latest"
],
"env": {
"API_KEY": "${MY_API_KEY}"
},
"description": "Docker MCP with workspace access"
}
}
}
```
## 🛡️ Security-Focused Configs
### Read-Only Filesystem
```json
{
"mcpServers": {
"readonly-docs": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"./docs",
"./README.md"
],
"includeTools": ["read_file", "list_directory", "search_files"],
"excludeTools": [
"write_file",
"create_directory",
"move_file",
"delete_file"
],
"trust": true,
"description": "Read-only documentation access"
}
}
}
```
### Untrusted External Server
```json
{
"mcpServers": {
"external-api": {
"httpUrl": "https://external-mcp.example.com/api",
"trust": false,
"timeout": 15000,
"includeTools": ["search", "analyze"],
"description": "External API (requires confirmation)"
}
}
}
```
## 📊 Database Access
### PostgreSQL MCP Server
```json
{
"mcpServers": {
"postgres": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"${DATABASE_URL}"
],
"env": {
"DATABASE_URL": "$POSTGRES_CONNECTION_STRING"
},
"timeout": 30000,
"trust": false,
"description": "PostgreSQL database access"
}
}
}
```
## 🧪 Testing & Development
### Test Environment
```json
{
"mcpServers": {
"test-files": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"./tests",
"./fixtures"
],
"trust": true,
"description": "Test files and fixtures"
},
"test-memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": true,
"description": "Test session memory"
}
}
}
```
### Debug Configuration
```json
{
"mcpServers": {
"debug-server": {
"command": "node",
"args": ["--inspect", "mcp-server.js"],
"env": {
"DEBUG": "*",
"LOG_LEVEL": "verbose"
},
"timeout": 60000,
"description": "MCP server with debugging enabled"
}
}
}
```
## 🔄 CI/CD Integration
### GitHub Actions Environment
```json
{
"mcpServers": {
"ci-workspace": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"${GITHUB_WORKSPACE}"
],
"env": {
"GITHUB_TOKEN": "$GITHUB_TOKEN",
"CI": "true"
},
"trust": true,
"description": "CI/CD workspace access"
}
}
}
```
## 🌟 Advanced Patterns
### Multiple Servers Same Type
```json
{
"mcpServers": {
"project-a": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "../project-a"],
"description": "Project A files"
},
"project-b": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "../project-b"],
"description": "Project B files"
},
"shared-memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"description": "Shared knowledge across projects"
}
}
}
```
### Conditional Server Selection
User-level config (`~/.qwen/settings.json`):
```json
{
"mcpServers": {
"global-memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": true,
"description": "Global memory across all projects"
}
}
}
```
Project-level config (`.qwen/settings.json`):
```json
{
"mcpServers": {
"project-files": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"trust": true,
"description": "Project-specific files"
}
}
}
```
## 📝 Configuration Validation
### Check Your Config
```bash
# List configured servers
qwen mcp list
# Show server details and schemas
qwen mcp list --schema
# Test connection
qwen mcp list --descriptions
```
### Common Mistakes
**Wrong:**
```json
{
"mcpServers": {
"server": {
"command": "mcp-server", // Not in PATH
"args": ["./"]
}
}
}
```
**Correct:**
```json
{
"mcpServers": {
"server": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"description": "Uses npx to ensure server is available"
}
}
}
```
## 🎯 Best Practices
1. **Use descriptive names** - Make server purposes clear
2. **Set appropriate timeouts** - Match your server's response time
3. **Trust local servers** - Skip confirmation for your own tools
4. **Filter tools** - Use `includeTools`/`excludeTools` for security
5. **Document configs** - Add descriptions for team members
6. **Environment variables** - Keep secrets out of configs
7. **Test independently** - Verify servers work before configuring
## 🔗 Quick Copy-Paste Configs
### Starter Pack
```json
{
"mcpServers": {
"files": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"trust": true
},
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": true
}
}
}
```
### Documentation Project
```json
{
"mcpServers": {
"docs": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./docs"],
"includeTools": ["read_file", "list_directory"],
"trust": true
}
}
}
```
### Full-Stack Development
```json
{
"mcpServers": {
"frontend": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./frontend"],
"trust": true
},
"backend": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./backend"],
"trust": true
},
"shared": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./shared"],
"trust": true
},
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": true
}
}
}
```
---
**Need help?** Check `qwen mcp --help` or refer to the [complete MCP documentation](./tools/mcp-server.md).

View File

@@ -1,418 +0,0 @@
# MCP Quick Start Guide - Practical Examples
This guide provides real-world examples to get you started with Model Context Protocol (MCP) servers in Qwen Code.
## 🚀 Getting Started in 5 Minutes
### Step 1: Install MCP Servers
Install official MCP servers from Anthropic:
```bash
# Filesystem access
npm install -g @modelcontextprotocol/server-filesystem
# Memory & Knowledge Graph
npm install -g @modelcontextprotocol/server-memory
# Sequential thinking
npm install -g @modelcontextprotocol/server-sequential-thinking
```
### Step 2: Configure Your First MCP Server
Create or edit `.qwen/settings.json` in your project:
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"description": "Access project files"
}
}
}
```
### Step 3: Verify Connection
```bash
qwen mcp list
```
You should see:
```
✓ filesystem: npx -y @modelcontextprotocol/server-filesystem ./ (stdio) - Connected
```
## 📚 Practical Examples
### Example 1: Local Development Assistant
**Use Case:** Work on a Node.js project with file access and memory.
**Configuration (`.qwen/settings.json`):**
```json
{
"mcpServers": {
"project-files": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"./src",
"./tests",
"./docs"
],
"description": "Access source code, tests, and documentation",
"trust": true
},
"project-memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"description": "Remember project decisions and context",
"trust": true
}
}
}
```
**Usage:**
```bash
qwen
> Remember: This project uses React 18 with TypeScript and follows Airbnb style guide
> List all files in the src directory
> Read src/App.tsx and suggest improvements
```
### Example 2: Multi-Repository Development
**Use Case:** Working across multiple codebases simultaneously.
**Configuration:**
```json
{
"mcpServers": {
"frontend": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"../frontend-app"
],
"description": "Frontend repository access"
},
"backend": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"../backend-api"
],
"description": "Backend repository access"
},
"shared-memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"description": "Shared knowledge across repositories"
}
}
}
```
### Example 3: Documentation-Only Access
**Use Case:** Safe access to documentation without risking code changes.
**Configuration:**
```json
{
"mcpServers": {
"docs": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"./docs",
"./README.md"
],
"description": "Read-only documentation access",
"trust": true,
"includeTools": ["read_file", "list_directory"]
}
}
}
```
### Example 4: Custom Python MCP Server
**Use Case:** Integrate custom Python tools via MCP.
**Server File (`mcp_server.py`):**
```python
#!/usr/bin/env python3
import sys
from mcp.server.stdio import stdio_server
from mcp.server import Server
from mcp.types import Tool, TextContent
server = Server("custom-tools")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="analyze_python_code",
description="Static analysis of Python code",
inputSchema={
"type": "object",
"properties": {
"file_path": {"type": "string"}
},
"required": ["file_path"]
}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "analyze_python_code":
# Your custom logic here
return [TextContent(type="text", text=f"Analysis of {arguments['file_path']}")]
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())
```
**Configuration:**
```json
{
"mcpServers": {
"python-tools": {
"command": "python",
"args": ["mcp_server.py"],
"env": {
"PYTHONPATH": "${PWD}"
},
"description": "Custom Python analysis tools"
}
}
}
```
### Example 5: Docker-Based MCP Server
**Use Case:** Run MCP servers in isolated containers.
**Configuration:**
```json
{
"mcpServers": {
"containerized-tools": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-v",
"${PWD}:/workspace",
"-w",
"/workspace",
"my-mcp-server:latest"
],
"description": "MCP tools running in Docker"
}
}
}
```
## 🔧 Configuration Tips
### Environment Variables
Use environment variables for sensitive data:
```json
{
"mcpServers": {
"api-server": {
"command": "node",
"args": ["api-server.js"],
"env": {
"API_KEY": "${MY_API_KEY}",
"DATABASE_URL": "$DB_CONNECTION"
}
}
}
}
```
### Trust Settings
Trust servers you control to skip confirmation dialogs:
```json
{
"mcpServers": {
"trusted-server": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"trust": true
}
}
}
```
### Tool Filtering
Limit which tools are available:
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"includeTools": ["read_file", "list_directory"],
"excludeTools": ["write_file", "move_file"]
}
}
}
```
## 🎯 Common Use Cases
### Code Review Assistant
```json
{
"mcpServers": {
"codebase": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"description": "Full codebase access for reviews"
},
"review-memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"description": "Remember review comments and patterns"
}
}
}
```
**Usage:**
```bash
qwen
> Review the changes in src/components/
> Remember: We follow the single responsibility principle
> Check if all new components have tests
```
### Documentation Generator
```json
{
"mcpServers": {
"source": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./src"],
"includeTools": ["read_file", "list_directory"]
},
"docs-writer": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./docs"],
"includeTools": ["write_file", "create_directory"]
}
}
}
```
### Learning Assistant
```json
{
"mcpServers": {
"tutorials": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"./tutorials",
"./examples"
],
"trust": true
},
"learning-progress": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"description": "Track learning progress and concepts"
}
}
}
```
## 🛠️ Troubleshooting
### Server Won't Connect
1. **Check the command is accessible:**
```bash
npx -y @modelcontextprotocol/server-filesystem ./
```
2. **Verify directory permissions:**
```bash
ls -la ./
```
3. **Check logs:**
```bash
qwen --debug
```
### No Tools Discovered
Ensure the server actually provides tools:
```bash
qwen mcp list --schema
```
### Tools Not Executing
- Check parameter schemas match
- Verify timeout settings (increase if needed)
- Test the server independently first
## 📖 Further Reading
- [MCP Server Documentation](./tools/mcp-server.md) - Complete reference
- [Official MCP Specification](https://modelcontextprotocol.io/) - Protocol details
- [MCP Server Examples](https://github.com/modelcontextprotocol/servers) - Community servers
## 🎓 Next Steps
1. ✅ Configure your first MCP server
2. ✅ Verify connection with `qwen mcp list`
3. ✅ Try basic file operations
4. ✅ Add memory for persistent context
5. ✅ Explore community MCP servers
6. ✅ Build your own custom server
---
**Pro Tip:** Start with trusted local servers (`trust: true`) for faster iteration, then add confirmation for production use.

View File

@@ -1,382 +0,0 @@
# MCP Testing & Validation Guide
This guide helps you test and validate your MCP server configurations.
## ✅ Quick Validation Checklist
### 1. Check MCP Servers Are Configured
```bash
qwen mcp list
```
**Expected output:**
```
Configured MCP servers:
✓ filesystem: npx -y @modelcontextprotocol/server-filesystem ./ (stdio) - Connected
✓ memory: npx -y @modelcontextprotocol/server-memory (stdio) - Connected
```
**Status indicators:**
- ✓ (green) - Connected successfully
- ✗ (red) - Connection failed or not connected
### 2. Verify Server Is Installed
Test the server command directly:
```bash
# Filesystem server
npx -y @modelcontextprotocol/server-filesystem --help
# Memory server
npx -y @modelcontextprotocol/server-memory --help
# Custom server
python mcp_server.py --help
```
### 3. Check Configuration File Syntax
Validate your JSON configuration:
```bash
# Linux/macOS
cat .qwen/settings.json | jq .
# Windows PowerShell
Get-Content .qwen/settings.json | ConvertFrom-Json | ConvertTo-Json
```
### 4. Test Within Qwen Code Session
Start an interactive session and check MCP status:
```bash
qwen
# Inside the session:
/mcp # Show all MCP servers and tools
/mcp desc # Show tool descriptions
/mcp schema # Show tool parameter schemas
```
## 🧪 Test Cases
### Test Case 1: Filesystem Server
**Configuration:**
```json
{
"mcpServers": {
"test-fs": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"trust": true
}
}
}
```
**Validation:**
1. List files: Start `qwen` and ask "List all files in this directory"
2. Read file: "Read the README.md file"
3. Verify output contains actual file contents
**Expected Tools:**
- `read_file` - Read file contents
- `write_file` - Write to files
- `list_directory` - List directory contents
- `create_directory` - Create directories
- `move_file` - Move/rename files
- `search_files` - Search for files
- `get_file_info` - Get file metadata
### Test Case 2: Memory Server
**Configuration:**
```json
{
"mcpServers": {
"test-memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": true
}
}
}
```
**Validation:**
1. Store information: "Remember that this project uses React 18"
2. Query: "What JavaScript framework does this project use?"
3. Verify it recalls the information from step 1
**Expected Tools:**
- `create_entities` - Create knowledge entities
- `create_relations` - Create relationships between entities
- `add_observations` - Add observations to entities
- `delete_entities` - Remove entities
- `delete_observations` - Remove observations
- `delete_relations` - Remove relationships
- `read_graph` - Read entire knowledge graph
- `search_nodes` - Search for specific nodes
- `open_nodes` - Open specific nodes by name
### Test Case 3: Multiple Servers
**Configuration:**
```json
{
"mcpServers": {
"files": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"trust": true
},
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"trust": true
}
}
}
```
**Validation:**
1. Check both servers are connected: `qwen mcp list`
2. Use filesystem tool: "List all JavaScript files"
3. Use memory tool: "Remember that we prefer TypeScript"
4. Verify both tools work simultaneously
### Test Case 4: Tool Filtering
**Configuration:**
```json
{
"mcpServers": {
"readonly-fs": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"includeTools": ["read_file", "list_directory"],
"trust": true
}
}
}
```
**Validation:**
1. Start qwen session
2. Run `/mcp desc` to list available tools
3. Verify only `read_file` and `list_directory` are present
4. Verify `write_file`, `create_directory`, etc. are NOT available
### Test Case 5: Untrusted Server Confirmation
**Configuration:**
```json
{
"mcpServers": {
"untrusted": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"],
"trust": false
}
}
}
```
**Validation:**
1. Ask qwen to read a file
2. Confirmation dialog should appear before execution
3. Options should include:
- Proceed once
- Always allow this tool
- Always allow this server
- Cancel
## 🔍 Debugging Failed Connections
### Issue: Server Shows "Disconnected"
**Diagnostic steps:**
1. **Test command manually:**
```bash
npx -y @modelcontextprotocol/server-filesystem ./
```
2. **Check for errors:**
```bash
qwen --debug
```
3. **Verify paths are correct:**
```bash
# Check if directory exists
ls ./
# Check if command is in PATH
which npx # Linux/macOS
where npx # Windows
```
4. **Check permissions:**
```bash
# Verify read/execute permissions
ls -la ./
```
5. **Review environment variables:**
```bash
echo $PATH
echo $PYTHONPATH # For Python servers
```
### Issue: No Tools Discovered
**Diagnostic steps:**
1. **Verify server implements MCP protocol:**
```bash
# For stdio servers, test input/output manually
echo '{"jsonrpc": "2.0", "method": "initialize", "id": 1}' | npx -y @modelcontextprotocol/server-filesystem ./
```
2. **Check server logs:**
```bash
# Some servers log to stderr
qwen --debug 2>&1 | grep MCP
```
3. **Verify server version:**
```bash
npm list -g @modelcontextprotocol/server-filesystem
```
### Issue: Tools Fail to Execute
**Diagnostic steps:**
1. **Check parameter format:**
- Ensure parameters match the expected schema
- Verify JSON encoding is correct
2. **Increase timeout:**
```json
{
"mcpServers": {
"slow-server": {
"command": "...",
"timeout": 60000
}
}
}
```
3. **Check server implementation:**
- Verify the server actually implements the tool
- Test the tool independently if possible
## 📊 Validation Results
### Expected Server Connection Times
| Server Type | Typical Connection Time | Timeout Recommendation |
|-------------|------------------------|------------------------|
| Filesystem | < 1 second | 10-30 seconds |
| Memory | < 1 second | 10-30 seconds |
| HTTP/SSE | 1-3 seconds | 30-60 seconds |
| Custom Python | 2-5 seconds | 30-60 seconds |
| Docker | 5-10 seconds | 60-120 seconds |
### Tool Execution Times
| Tool Type | Typical Duration | Timeout Recommendation |
|-------------------|-----------------|------------------------|
| Read file | < 100ms | 5-10 seconds |
| List directory | < 500ms | 10-15 seconds |
| Search files | 1-5 seconds | 30-60 seconds |
| Memory operations | < 1 second | 10-30 seconds |
| API calls | 1-10 seconds | 30-120 seconds |
## 🎯 Success Criteria
Your MCP configuration is working correctly if:
✅ `qwen mcp list` shows all servers as "Connected"
✅ `/mcp` command in qwen session displays tools
✅ Tool executions complete without errors
✅ Confirmation dialogs appear for untrusted servers (if `trust: false`)
✅ Tool filtering works as expected (include/exclude)
✅ Environment variables are properly substituted
✅ Timeouts are appropriate for your server's response time
## 🚀 Performance Tips
1. **Use `trust: true` for local servers** to skip confirmation dialogs
2. **Set appropriate timeouts** - too low causes failures, too high slows down errors
3. **Filter tools** - Only enable tools you actually need
4. **Test servers independently** before configuring in qwen
5. **Use `--debug` flag** during initial setup
6. **Monitor resource usage** for long-running or resource-intensive servers
## 📝 Validation Script Example
Create a test script to automate validation:
```bash
#!/bin/bash
# validate-mcp.sh
echo "Testing MCP configuration..."
# Test 1: Check config file exists
if [ ! -f .qwen/settings.json ]; then
echo "❌ Missing .qwen/settings.json"
exit 1
fi
echo "✅ Config file exists"
# Test 2: Validate JSON syntax
if ! cat .qwen/settings.json | jq . > /dev/null 2>&1; then
echo "❌ Invalid JSON in settings.json"
exit 1
fi
echo "✅ Valid JSON syntax"
# Test 3: Check servers are configured
SERVER_COUNT=$(cat .qwen/settings.json | jq '.mcpServers | length')
if [ "$SERVER_COUNT" -eq 0 ]; then
echo "❌ No MCP servers configured"
exit 1
fi
echo "✅ $SERVER_COUNT MCP server(s) configured"
# Test 4: Check connection status
qwen mcp list | grep -q "Connected"
if [ $? -eq 0 ]; then
echo "✅ At least one server is connected"
else
echo "❌ No servers connected"
exit 1
fi
echo ""
echo "✅ All validation checks passed!"
```
## 📚 Next Steps
After validation:
1. **Start using MCP tools** in your workflow
2. **Document your custom configurations** for team members
3. **Share your successful configs** with the community
4. **Monitor performance** and adjust timeouts as needed
5. **Explore more MCP servers** from the community
---
**Having issues?** Check the [MCP troubleshooting guide](./tools/mcp-server.md#troubleshooting) or open an issue on GitHub.

View File

@@ -54,7 +54,3 @@ Qwen Code's built-in tools can be broadly categorized as follows:
Additionally, these tools incorporate:
- **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the model and your local environment or other services like APIs.
- **[MCP Quick Start Guide](../mcp-quick-start.md)**: Get started with MCP in 5 minutes with practical examples
- **[MCP Example Configurations](../mcp-example-configs.md)**: Ready-to-use configurations for common scenarios
- **[MCP Testing & Validation](../mcp-testing-validation.md)**: Test and validate your MCP server setups
- **[Sandboxing](../sandbox.md)**: Sandboxing isolates the model and its changes from your environment to reduce potential risk.

View File

@@ -75,8 +75,6 @@ export default tseslint.config(
},
},
rules: {
// We use TypeScript for React components; prop-types are unnecessary
'react/prop-types': 'off',
// General Best Practice Rules (subset adapted for flat config)
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'arrow-body-style': ['error', 'as-needed'],
@@ -113,14 +111,10 @@ export default tseslint.config(
{
allow: [
'react-dom/test-utils',
'react-dom/client',
'memfs/lib/volume.js',
'yargs/**',
'msw/node',
'**/generated/**',
'./styles/tailwind.css',
'./styles/App.css',
'./styles/style.css'
'**/generated/**'
],
},
],

View File

@@ -13,6 +13,8 @@ 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';
@@ -23,14 +25,6 @@ type PendingRequest = {
timeout: NodeJS.Timeout;
};
type UsageMetadata = {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
};
type SessionUpdateNotification = {
sessionId?: string;
update?: {
@@ -45,9 +39,6 @@ type SessionUpdateNotification = {
text?: string;
};
modeId?: string;
_meta?: {
usage?: UsageMetadata;
};
};
};
@@ -95,14 +86,10 @@ function setupAcpTest(
const permissionHandler =
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
const agent = spawn(
'node',
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
{
cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'],
},
);
const agent = spawn('node', [rig.bundlePath, '--experimental-acp'], {
cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'],
});
agent.stderr?.on('data', (chunk) => {
stderr.push(chunk.toString());
@@ -266,11 +253,11 @@ function setupAcpTest(
}
(IS_SANDBOX ? describe.skip : describe)('acp integration', () => {
it('basic smoke test', async () => {
it('creates, lists, loads, and resumes a session', async () => {
const rig = new TestRig();
rig.setup('acp load session');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
try {
const initResult = await sendRequest('initialize', {
@@ -296,6 +283,34 @@ function setupAcpTest(
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(''));
@@ -572,52 +587,4 @@ function setupAcpTest(
await cleanup();
}
});
it('receives usage metadata in agent_message_chunk updates', async () => {
const rig = new TestRig();
rig.setup('acp usage metadata');
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
try {
await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say "hello".' }],
});
await delay(500);
// Find updates with usage metadata
const updatesWithUsage = sessionUpdates.filter(
(u) =>
u.update?.sessionUpdate === 'agent_message_chunk' &&
u.update?._meta?.usage,
);
expect(updatesWithUsage.length).toBeGreaterThan(0);
const usage = updatesWithUsage[0].update?._meta?.usage;
expect(usage).toBeDefined();
expect(
typeof usage?.promptTokens === 'number' ||
typeof usage?.totalTokens === 'number',
).toBe(true);
} catch (e) {
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
throw e;
} finally {
await cleanup();
}
});
});

View File

@@ -438,8 +438,12 @@ describe('Configuration Options (E2E)', () => {
}
});
// Skip - qwen-oauth requires user interaction which is not possible in CI environments
it.skip('should accept authType: qwen-oauth', async () => {
// Skip in containerized sandbox environments - qwen-oauth requires user interaction
// which is not possible in Docker/Podman CI environments
it.skipIf(
process.env['SANDBOX'] === 'sandbox:docker' ||
process.env['SANDBOX'] === 'sandbox:podman',
)('should accept authType: qwen-oauth', async () => {
// Note: qwen-oauth requires credentials in ~/.qwen and user interaction
// Without credentials, the auth process will timeout waiting for user
// This test verifies the option is accepted and passed correctly to CLI

View File

@@ -1195,7 +1195,7 @@ describe('Permission Control (E2E)', () => {
});
describe('mode comparison tests', () => {
it.skip(
it(
'should demonstrate different behaviors across all modes for write operations',
async () => {
const modes: Array<'default' | 'auto-edit' | 'yolo'> = [

View File

@@ -73,26 +73,15 @@ export class SDKTestHelper {
await mkdir(this.testDir, { recursive: true });
// Optionally create .qwen/settings.json for CLI configuration
if (options.createQwenConfig !== false) {
if (options.createQwenConfig) {
const qwenDir = join(this.testDir, '.qwen');
await mkdir(qwenDir, { recursive: true });
const optionsSettings = options.settings ?? {};
const generalSettings =
typeof optionsSettings['general'] === 'object' &&
optionsSettings['general'] !== null
? (optionsSettings['general'] as Record<string, unknown>)
: {};
const settings = {
...optionsSettings,
telemetry: {
enabled: false, // SDK tests don't need telemetry
},
general: {
...generalSettings,
chatRecording: false, // SDK tests don't need chat recording
},
...options.settings,
};
await writeFile(

View File

@@ -31,7 +31,9 @@ describe('Tool Control Parameters (E2E)', () => {
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('tool-control');
testDir = await helper.setup('tool-control', {
createQwenConfig: false,
});
});
afterEach(async () => {

View File

@@ -218,8 +218,8 @@ export class TestRig {
process.env.INTEGRATION_TEST_USE_INSTALLED_GEMINI === 'true';
const command = isNpmReleaseTest ? 'qwen' : 'node';
const initialArgs = isNpmReleaseTest
? ['--no-chat-recording', ...extraInitialArgs]
: [this.bundlePath, '--no-chat-recording', ...extraInitialArgs];
? extraInitialArgs
: [this.bundlePath, ...extraInitialArgs];
return { command, initialArgs };
}

1279
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.5.0",
"version": "0.4.0-preview.1",
"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.5.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0-preview.1"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -93,7 +93,7 @@
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^10.5.0",
"glob": "^10.4.5",
"globals": "^16.0.0",
"google-artifactregistry-auth": "^3.4.0",
"husky": "^9.1.7",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.5.0",
"version": "0.4.0-preview.1",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0-preview.1"
},
"dependencies": {
"@google/genai": "1.16.0",
@@ -47,7 +47,7 @@
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fzf": "^0.5.2",
"glob": "^10.5.0",
"glob": "^10.4.5",
"highlight.js": "^11.11.1",
"ink": "^6.2.3",
"ink-gradient": "^3.0.0",
@@ -63,8 +63,8 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"undici": "^6.22.0",
"tar": "^7.5.1",
"undici": "^7.10.0",
"extract-zip": "^2.0.1",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",

View File

@@ -88,16 +88,6 @@ export class AgentSideConnection implements Client {
);
}
/**
* 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,
);
}
/**
* Request permission before running a tool
*
@@ -251,11 +241,9 @@ class Connection {
).toResult();
}
let errorName;
let details;
if (error instanceof Error) {
errorName = error.name;
details = error.message;
} else if (
typeof error === 'object' &&
@@ -266,10 +254,6 @@ class Connection {
details = error.message;
}
if (errorName === 'TokenManagerError') {
return RequestError.authRequired(details).toResult();
}
return RequestError.internalError(details).toResult();
}
}
@@ -373,7 +357,6 @@ export interface Client {
params: schema.RequestPermissionRequest,
): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>;
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>;

View File

@@ -6,19 +6,15 @@
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,
QwenOAuth2Event,
qwenOAuth2Events,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
type Config,
type ConversationRecord,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
@@ -127,33 +123,13 @@ class GeminiAgent {
async authenticate({ methodId }: acp.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 } });
};
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
}
await clearCachedCredentialFile();
try {
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
} finally {
// Ensure we don't leak listeners if auth fails early.
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
}
}
return;
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
}
async newSession({
@@ -292,17 +268,14 @@ class GeminiAgent {
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
if (!selectedType) {
throw acp.RequestError.authRequired('No Selected Type');
throw acp.RequestError.authRequired();
}
try {
// Use true for the second argument to ensure only cached credentials are used
await config.refreshAuth(selectedType, true);
await config.refreshAuth(selectedType);
} catch (e) {
console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired(
'Authentication failed: ' + (e as Error).message,
);
throw acp.RequestError.authRequired();
}
}

View File

@@ -20,7 +20,6 @@ export const AGENT_METHODS = {
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',
};
@@ -58,6 +57,8 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
@@ -246,13 +247,7 @@ 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 authenticateResponseSchema = z.null();
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
@@ -321,23 +316,6 @@ export const annotationsSchema = z.object({
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(),
});
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
export const requestPermissionResponseSchema = z.object({
outcome: requestPermissionOutcomeSchema,
});
@@ -522,12 +500,10 @@ export const sessionUpdateSchema = z.union([
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(),
@@ -560,6 +536,7 @@ export const sessionUpdateSchema = z.union([
export const agentResponseSchema = z.union([
initializeResponseSchema,
authenticateResponseSchema,
newSessionResponseSchema,
loadSessionResponseSchema,
promptResponseSchema,

View File

@@ -1,59 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import { AcpFileSystemService } from './filesystem.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
findFiles: vi.fn().mockReturnValue([]),
});
describe('AcpFileSystemService', () => {
describe('readTextFile ENOENT handling', () => {
it('parses path from ACP ENOENT message (quoted)', async () => {
const client = {
readTextFile: vi
.fn()
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-1',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
code: 'ENOENT',
path: '/remote/file.txt',
});
});
it('falls back to requested path when none provided', async () => {
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-2',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(
svc.readTextFile('/fallback/path.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
path: '/fallback/path.txt',
});
});
});
});

View File

@@ -30,20 +30,6 @@ export class AcpFileSystemService implements FileSystemService {
limit: null,
});
if (response.content.startsWith('ERROR: ENOENT:')) {
// Treat ACP error strings as structured ENOENT errors without
// assuming a specific platform format.
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
const err = new Error(response.content) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
const rawPath = match?.groups?.['path']?.trim();
err['path'] = rawPath
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
: filePath;
throw err;
}
return response.content;
}

View File

@@ -411,48 +411,4 @@ describe('HistoryReplayer', () => {
]);
});
});
describe('usage metadata replay', () => {
it('should emit usage metadata after assistant message content', async () => {
const record: ChatRecord = {
uuid: 'assistant-uuid',
parentUuid: 'user-uuid',
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'assistant',
cwd: '/test',
version: '1.0.0',
message: {
role: 'model',
parts: [{ text: 'Hello!' }],
},
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello!' },
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
thoughtsTokens: undefined,
totalTokens: 150,
cachedTokens: undefined,
},
},
});
});
});
});

View File

@@ -4,11 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
import type {
Content,
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import type { ChatRecord } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
import type { SessionContext } from './types.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
@@ -55,9 +52,6 @@ export class HistoryReplayer {
if (record.message) {
await this.replayContent(record.message, 'assistant');
}
if (record.usageMetadata) {
await this.replayUsageMetadata(record.usageMetadata);
}
break;
case 'tool_result':
@@ -94,22 +88,11 @@ export class HistoryReplayer {
toolName: functionName,
callId,
args: part.functionCall.args as Record<string, unknown>,
status: 'in_progress',
});
}
}
}
/**
* Replays usage metadata.
* @param usageMetadata - The usage metadata to replay
*/
private async replayUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
): Promise<void> {
await this.messageEmitter.emitUsageMetadata(usageMetadata);
}
/**
* Replays a tool result record.
*/
@@ -135,54 +118,6 @@ export class HistoryReplayer {
// Note: args aren't stored in tool_result records by default
args: undefined,
});
// Special handling: Task tool execution summary contains token usage
const { resultDisplay } = result ?? {};
if (
!!resultDisplay &&
typeof resultDisplay === 'object' &&
'type' in resultDisplay &&
(resultDisplay as { type?: unknown }).type === 'task_execution'
) {
await this.emitTaskUsageFromResultDisplay(
resultDisplay as TaskResultDisplay,
);
}
}
/**
* Emits token usage from a TaskResultDisplay execution summary, if present.
*/
private async emitTaskUsageFromResultDisplay(
resultDisplay: TaskResultDisplay,
): Promise<void> {
const summary = resultDisplay.executionSummary;
if (!summary) {
return;
}
const usageMetadata: GenerateContentResponseUsageMetadata = {};
if (Number.isFinite(summary.inputTokens)) {
usageMetadata.promptTokenCount = summary.inputTokens;
}
if (Number.isFinite(summary.outputTokens)) {
usageMetadata.candidatesTokenCount = summary.outputTokens;
}
if (Number.isFinite(summary.thoughtTokens)) {
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
}
if (Number.isFinite(summary.cachedTokens)) {
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
}
if (Number.isFinite(summary.totalTokens)) {
usageMetadata.totalTokenCount = summary.totalTokens;
}
// Only emit if we captured at least one token metric
if (Object.keys(usageMetadata).length > 0) {
await this.messageEmitter.emitUsageMetadata(usageMetadata);
}
}
/**

View File

@@ -4,12 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Content,
FunctionCall,
GenerateContentResponseUsageMetadata,
Part,
} from '@google/genai';
import type { Content, FunctionCall, Part } from '@google/genai';
import type {
Config,
GeminiChat,
@@ -60,7 +55,6 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
import { HistoryReplayer } from './HistoryReplayer.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { PlanEmitter } from './emitters/PlanEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { SubAgentTracker } from './SubAgentTracker.js';
/**
@@ -85,7 +79,6 @@ export class Session implements SessionContext {
private readonly historyReplayer: HistoryReplayer;
private readonly toolCallEmitter: ToolCallEmitter;
private readonly planEmitter: PlanEmitter;
private readonly messageEmitter: MessageEmitter;
// Implement SessionContext interface
readonly sessionId: string;
@@ -103,7 +96,6 @@ export class Session implements SessionContext {
this.toolCallEmitter = new ToolCallEmitter(this);
this.planEmitter = new PlanEmitter(this);
this.historyReplayer = new HistoryReplayer(this);
this.messageEmitter = new MessageEmitter(this);
}
getId(): string {
@@ -200,8 +192,6 @@ export class Session implements SessionContext {
}
const functionCalls: FunctionCall[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
const streamStartTime = Date.now();
try {
const responseStream = await chat.sendMessageStream(
@@ -232,16 +222,18 @@ export class Session implements SessionContext {
continue;
}
this.messageEmitter.emitMessage(
part.text,
'assistant',
part.thought,
);
}
}
const content: acp.ContentBlock = {
type: 'text',
text: part.text,
};
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
usageMetadata = resp.value.usageMetadata;
this.sendUpdate({
sessionUpdate: part.thought
? 'agent_thought_chunk'
: 'agent_message_chunk',
content,
});
}
}
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
@@ -259,15 +251,6 @@ export class Session implements SessionContext {
throw error;
}
if (usageMetadata) {
const durationMs = Date.now() - streamStartTime;
await this.messageEmitter.emitUsageMetadata(
usageMetadata,
'',
durationMs,
);
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
@@ -461,9 +444,7 @@ export class Session implements SessionContext {
}
const confirmationDetails =
this.config.getApprovalMode() !== ApprovalMode.YOLO
? await invocation.shouldConfirmExecute(abortSignal)
: false;
await invocation.shouldConfirmExecute(abortSignal);
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];
@@ -541,7 +522,6 @@ export class Session implements SessionContext {
callId,
toolName: fc.name,
args,
status: 'in_progress',
};
await this.toolCallEmitter.emitStart(startParams);
}

View File

@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'pending',
status: 'in_progress',
title: 'read_file',
content: [],
locations: [],

View File

@@ -9,7 +9,6 @@ import type {
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
@@ -21,7 +20,6 @@ import {
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';
/**
@@ -64,7 +62,6 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
*/
export class SubAgentTracker {
private readonly toolCallEmitter: ToolCallEmitter;
private readonly messageEmitter: MessageEmitter;
private readonly toolStates = new Map<
string,
{
@@ -79,7 +76,6 @@ export class SubAgentTracker {
private readonly client: acp.Client,
) {
this.toolCallEmitter = new ToolCallEmitter(ctx);
this.messageEmitter = new MessageEmitter(ctx);
}
/**
@@ -96,19 +92,16 @@ export class SubAgentTracker {
const onToolCall = this.createToolCallHandler(abortSignal);
const onToolResult = this.createToolResultHandler(abortSignal);
const onApproval = this.createApprovalHandler(abortSignal);
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
return [
() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
// Clean up any remaining states
this.toolStates.clear();
},
@@ -259,20 +252,6 @@ export class SubAgentTracker {
};
}
/**
* Creates a handler for usage metadata events.
*/
private createUsageMetadataHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
};
}
/**
* Converts confirmation details to permission options for the client.
*/

View File

@@ -148,59 +148,4 @@ describe('MessageEmitter', () => {
});
});
});
describe('emitUsageMetadata', () => {
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
const usageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 50,
thoughtsTokenCount: 25,
totalTokenCount: 175,
cachedContentTokenCount: 10,
};
await emitter.emitUsageMetadata(usageMetadata);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
thoughtsTokens: 25,
totalTokens: 175,
cachedTokens: 10,
},
},
});
});
it('should include durationMs in _meta when provided', async () => {
const usageMetadata = {
promptTokenCount: 10,
candidatesTokenCount: 5,
thoughtsTokenCount: 2,
totalTokenCount: 17,
cachedContentTokenCount: 1,
};
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'done' },
_meta: {
usage: {
promptTokens: 10,
completionTokens: 5,
thoughtsTokens: 2,
totalTokens: 17,
cachedTokens: 1,
},
durationMs: 1234,
},
});
});
});
});

View File

@@ -4,8 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { Usage } from '../../schema.js';
import { BaseEmitter } from './BaseEmitter.js';
/**
@@ -26,16 +24,6 @@ export class MessageEmitter extends BaseEmitter {
});
}
/**
* Emits an agent thought chunk.
*/
async emitAgentThought(text: string): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
});
}
/**
* Emits an agent message chunk.
*/
@@ -47,28 +35,12 @@ export class MessageEmitter extends BaseEmitter {
}
/**
* Emits usage metadata.
* Emits an agent thought chunk.
*/
async emitUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
): Promise<void> {
const usage: Usage = {
promptTokens: usageMetadata.promptTokenCount,
completionTokens: usageMetadata.candidatesTokenCount,
thoughtsTokens: usageMetadata.thoughtsTokenCount,
totalTokens: usageMetadata.totalTokenCount,
cachedTokens: usageMetadata.cachedContentTokenCount,
};
const meta =
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
async emitAgentThought(text: string): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
_meta: meta,
});
}

View File

@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'pending',
status: 'in_progress',
title: 'unknown_tool', // Falls back to tool name
content: [],
locations: [],
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-456',
status: 'pending',
status: 'in_progress',
title: 'edit_file: Test tool description',
content: [],
locations: [{ path: '/test/file.ts', line: 10 }],
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-fail',
status: 'pending',
status: 'in_progress',
title: 'failing_tool', // Fallback to tool name
content: [],
locations: [], // Fallback to empty
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
type: 'content',
content: {
type: 'text',
text: 'test output',
text: '{"output":"test output"}',
},
},
],
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
content: [
{
type: 'content',
content: { type: 'text', text: 'Function output' },
content: { type: 'text', text: '{"output":"Function output"}' },
},
],
rawOutput: 'raw result',

View File

@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: params.callId,
status: params.status || 'pending',
status: 'in_progress',
title,
content: [],
locations,
@@ -275,18 +275,7 @@ export class ToolCallEmitter extends BaseEmitter {
// Handle functionResponse parts - stringify the response
if ('functionResponse' in part && part.functionResponse) {
try {
const resp = part.functionResponse.response as Record<
string,
unknown
>;
const outputField = resp['output'];
const errorField = resp['error'];
const responseText =
typeof outputField === 'string'
? outputField
: typeof errorField === 'string'
? errorField
: JSON.stringify(resp);
const responseText = JSON.stringify(part.functionResponse.response);
result.push({
type: 'content',
content: { type: 'text', text: responseText },

View File

@@ -35,8 +35,6 @@ export interface ToolCallStartParams {
callId: string;
/** Arguments passed to the tool */
args?: Record<string, unknown>;
/** Status of the tool call */
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
}
/**

View File

@@ -130,11 +130,6 @@ export interface CliArgs {
inputFormat?: string | undefined;
outputFormat: string | undefined;
includePartialMessages?: boolean;
/**
* If chat recording is disabled, the chat history would not be recorded,
* so --continue and --resume would not take effect.
*/
chatRecording: boolean | undefined;
/** Resume the most recent session for the current project */
continue: boolean | undefined;
/** Resume a specific session by its ID */
@@ -143,7 +138,6 @@ export interface CliArgs {
coreTools: string[] | undefined;
excludeTools: string[] | undefined;
authType: string | undefined;
channel: string | undefined;
}
function normalizeOutputFormat(
@@ -238,11 +232,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'proxy',
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
)
.option('chat-recording', {
type: 'boolean',
description:
'Enable chat recording to disk. If false, chat history is not saved and --continue/--resume will not work.',
})
.command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) =>
yargsInstance
.positional('query', {
@@ -308,11 +297,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
description: 'Channel identifier (VSCode, ACP, SDK, CI)',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -575,12 +559,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
// Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP
if (result['experimentalAcp'] && !result['channel']) {
(result as Record<string, unknown>)['channel'] = 'ACP';
}
return result as unknown as CliArgs;
}
@@ -1005,12 +983,6 @@ export async function loadCliConfig(
output: {
format: outputSettingsFormat,
},
channel: argv.channel,
// Precedence: explicit CLI flag > settings file > default(true).
// NOTE: do NOT set a yargs default for `chat-recording`, otherwise argv will
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
});
}

View File

@@ -191,29 +191,8 @@ const SETTINGS_SCHEMA = {
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
],
},
terminalBell: {
type: 'boolean',
label: 'Terminal Bell',
category: 'General',
requiresRestart: false,
default: true,
description:
'Play terminal bell sound when response completes or needs approval.',
showInDialog: true,
},
chatRecording: {
type: 'boolean',
label: 'Chat Recording',
category: 'General',
requiresRestart: true,
default: true,
description:
'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.',
showInDialog: false,
},
},
},
output: {

View File

@@ -485,8 +485,6 @@ describe('gemini.tsx main function kitty protocol', () => {
excludeTools: undefined,
authType: undefined,
maxSessionTurns: undefined,
channel: undefined,
chatRecording: undefined,
});
await main();

View File

@@ -9,7 +9,7 @@ import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes
// State
let currentLanguage: SupportedLanguage = 'en';
@@ -51,12 +51,10 @@ export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang?.startsWith('zh')) return 'zh';
if (envLang?.startsWith('en')) return 'en';
if (envLang?.startsWith('ru')) return 'ru';
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh';
if (locale.startsWith('ru')) return 'ru';
} catch {
// Fallback to default
}

View File

@@ -867,7 +867,6 @@ export default {
// Exit Screen / Stats
// ============================================================================
'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!',
'To continue this session, run': 'To continue this session, run',
'Interaction Summary': 'Interaction Summary',
'Session ID:': 'Session ID:',
'Tool Calls:': 'Tool Calls:',

File diff suppressed because it is too large Load Diff

View File

@@ -820,7 +820,6 @@ export default {
// Exit Screen / Stats
// ============================================================================
'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!',
'To continue this session, run': '要继续此会话,请运行',
'Interaction Summary': '交互摘要',
'Session ID:': '会话 ID',
'Tool Calls:': '工具调用:',

View File

@@ -58,6 +58,7 @@ vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
vi.mock('../ui/commands/extensionsCommand.js', () => ({

View File

@@ -15,6 +15,7 @@ import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
@@ -62,6 +63,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
clearCommand,
compressCommand,
copyCommand,
corgiCommand,
docsCommand,
directoryCommand,
editorCommand,

View File

@@ -56,10 +56,10 @@ export const createMockCommandContext = (
pendingItem: null,
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
reloadCommands: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
session: {

View File

@@ -136,6 +136,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { settings, config, initializationResult } = props;
const historyManager = useHistory();
useMemoryMonitor(historyManager);
const [corgiMode, setCorgiMode] = useState(false);
const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -484,6 +485,7 @@ export const AppContainer = (props: AppContainerProps) => {
}, 100);
},
setDebugMessage,
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
@@ -496,6 +498,7 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog,
openModelDialog,
setDebugMessage,
setCorgiMode,
dispatchExtensionStateUpdate,
openPermissionsDialog,
openApprovalModeDialog,
@@ -942,7 +945,6 @@ export const AppContainer = (props: AppContainerProps) => {
isFocused,
streamingState,
elapsedTime,
settings,
});
// Dialog close functionality
@@ -1216,6 +1218,7 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,
@@ -1306,6 +1309,7 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { corgiCommand } from './corgiCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('corgiCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
});
it('should call the toggleCorgiMode function on the UI context', async () => {
if (!corgiCommand.action) {
throw new Error('The corgi command must have an action.');
}
await corgiCommand.action(mockContext, '');
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
});
it('should have the correct name and description', () => {
expect(corgiCommand.name).toBe('corgi');
expect(corgiCommand.description).toBe('Toggles corgi mode.');
});
});

View File

@@ -0,0 +1,17 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
hidden: true,
kind: CommandKind.BUILT_IN,
action: (context, _args) => {
context.ui.toggleCorgiMode();
},
};

View File

@@ -1,587 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
// Mock i18n module
vi.mock('../../i18n/index.js', () => ({
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
getCurrentLanguage: vi.fn().mockReturnValue('en'),
t: vi.fn((key: string) => key),
}));
// Mock settings module to avoid Storage side effect
vi.mock('../../config/settings.js', () => ({
SettingScope: {
User: 'user',
Workspace: 'workspace',
Default: 'default',
},
}));
// Mock fs module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
default: {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
},
};
});
// Mock Storage from core
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
Storage: {
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'),
},
};
});
// Import modules after mocking
import * as i18n from '../../i18n/index.js';
import { languageCommand } from './languageCommand.js';
describe('languageCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
},
settings: {
merged: {},
setValue: vi.fn(),
},
},
});
// Reset i18n mocks
vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en');
vi.mocked(i18n.t).mockImplementation((key: string) => key);
// Reset fs mocks
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('command metadata', () => {
it('should have the correct name', () => {
expect(languageCommand.name).toBe('language');
});
it('should have a description', () => {
expect(languageCommand.description).toBeDefined();
expect(typeof languageCommand.description).toBe('string');
});
it('should be a built-in command', () => {
expect(languageCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should have subcommands', () => {
expect(languageCommand.subCommands).toBeDefined();
expect(languageCommand.subCommands?.length).toBe(2);
});
it('should have ui and output subcommands', () => {
const subCommandNames = languageCommand.subCommands?.map((c) => c.name);
expect(subCommandNames).toContain('ui');
expect(subCommandNames).toContain('output');
});
});
describe('main command action - no arguments', () => {
it('should show current language settings when no arguments provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
});
it('should show available subcommands in help', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language ui'),
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language output'),
});
});
it('should show LLM output language when set', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
);
// Make t() function handle interpolation for this test
vi.mocked(i18n.t).mockImplementation(
(key: string, params?: Record<string, string>) => {
if (params && key.includes('{{lang}}')) {
return key.replace('{{lang}}', params['lang'] || '');
}
return key;
},
);
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
// Verify it correctly parses "Chinese" from the template format
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Chinese'),
});
});
});
describe('main command action - config not available', () => {
it('should return error when config is null', async () => {
mockContext.services.config = null;
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Configuration not available'),
});
});
});
describe('/language ui subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language ui'),
});
});
it('should set English with "en"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(mockContext.services.settings.setValue).toHaveBeenCalled();
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "en-US"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en-US');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "english"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui english');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh-CN"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh-CN');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "chinese"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui chinese');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for invalid language', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui invalid');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid language'),
});
});
it('should persist setting to user scope', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
await languageCommand.action(mockContext, 'ui en');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.anything(), // SettingScope.User
'general.language',
'en',
);
});
});
describe('/language output subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language output'),
});
});
it('should create LLM output language rule file', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Chinese');
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
it('should include restart notice in success message', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output Japanese');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('restart'),
});
});
it('should handle file write errors gracefully', async () => {
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output German');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Failed to generate'),
});
});
});
describe('backward compatibility - direct language arguments', () => {
it('should set Chinese with direct "zh" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with direct "en" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for unknown direct argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'unknown');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid command'),
});
});
});
describe('ui subcommand object', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
it('should have correct metadata', () => {
expect(uiSubcommand).toBeDefined();
expect(uiSubcommand?.name).toBe('ui');
expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have nested language subcommands', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US');
});
it('should have action that sets language', async () => {
if (!uiSubcommand?.action) {
throw new Error('UI subcommand must have an action.');
}
const result = await uiSubcommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
});
describe('output subcommand object', () => {
const outputSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'output',
);
it('should have correct metadata', () => {
expect(outputSubcommand).toBeDefined();
expect(outputSubcommand?.name).toBe('output');
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have action that generates rule file', async () => {
if (!outputSubcommand?.action) {
throw new Error('Output subcommand must have an action.');
}
// Ensure mocks are properly set for this test
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
const result = await outputSubcommand.action(mockContext, 'French');
expect(fs.writeFileSync).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('LLM output language rule file generated'),
});
});
});
describe('nested ui language subcommands', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
const zhCNSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'zh-CN',
);
const enUSSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'en-US',
);
it('zh-CN should have aliases', () => {
expect(zhCNSubcommand?.altNames).toContain('zh');
expect(zhCNSubcommand?.altNames).toContain('chinese');
});
it('en-US should have aliases', () => {
expect(enUSSubcommand?.altNames).toContain('en');
expect(enUSSubcommand?.altNames).toContain('english');
});
it('zh-CN action should set Chinese', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('en-US action should set English', async () => {
if (!enUSSubcommand?.action) {
throw new Error('en-US subcommand must have an action.');
}
const result = await enUSSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should reject extra arguments', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, 'extra args');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('do not accept additional arguments'),
});
});
});
});

View File

@@ -81,9 +81,8 @@ function getCurrentLlmOutputLanguage(): string | null {
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract language name from the first line
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
if (match) {
return match[1];
}
@@ -128,17 +127,16 @@ async function setUiLanguage(
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
const langDisplayNames: Record<SupportedLanguage, string> = {
zh: '中文zh-CN',
en: 'Englishen-US',
ru: 'Русский (ru-RU)',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang] || lang,
lang: langDisplayNames[lang],
}),
};
}
@@ -218,7 +216,7 @@ export const languageCommand: SlashCommand = {
: t('LLM output language not set'),
'',
t('Available subcommands:'),
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n');
@@ -234,7 +232,7 @@ export const languageCommand: SlashCommand = {
const subcommand = parts[0].toLowerCase();
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US|ru-RU]
// Handle /language ui [zh-CN|en-US]
if (parts.length === 1) {
// Show UI language subcommand help
return {
@@ -243,12 +241,11 @@ export const languageCommand: SlashCommand = {
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
t('Usage: /language ui [zh-CN|en-US]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
t(' - ru-RU: Russian'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
@@ -269,18 +266,11 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
content: t('Invalid language. Available: en-US, zh-CN'),
};
}
@@ -317,20 +307,13 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
@@ -440,29 +423,6 @@ export const languageCommand: SlashCommand = {
return setUiLanguage(context, 'en');
},
},
{
name: 'ru-RU',
altNames: ['ru', 'russian', 'русский'],
get description() {
return t('Set UI language to Russian (ru-RU)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'ru');
},
},
],
},
{

View File

@@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => {
const expectedSubstrings = [
`set -eEuo pipefail`,
`fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`,
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
];
for (const substring of expectedSubstrings) {
@@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => {
if (gitignoreExists) {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('.qwen/');
expect(gitignoreContent).toContain('.gemini/');
expect(gitignoreContent).toContain('gha-creds-*.json');
}
});
@@ -135,7 +135,7 @@ describe('updateGitignore', () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe('.qwen/\ngha-creds-*.json\n');
expect(content).toBe('.gemini/\ngha-creds-*.json\n');
});
it('appends entries to existing .gitignore file', async () => {
@@ -148,13 +148,13 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe(
'# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n',
'# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n',
);
});
it('does not add duplicate entries', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n';
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
@@ -166,7 +166,7 @@ describe('updateGitignore', () => {
it('adds only missing entries when some already exist', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.qwen/\nsome-other-file\n';
const existingContent = '.gemini/\nsome-other-file\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
@@ -174,17 +174,17 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add only the missing gha-creds-*.json entry
expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toContain('gha-creds-*.json');
// Should not duplicate .qwen/ entry
expect((content.match(/\.qwen\//g) || []).length).toBe(1);
// Should not duplicate .gemini/ entry
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
});
it('does not get confused by entries in comments or as substrings', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = [
'# This is a comment mentioning .qwen/ folder',
'my-app.qwen/config',
'# This is a comment mentioning .gemini/ folder',
'my-app.gemini/config',
'# Another comment with gha-creds-*.json pattern',
'some-other-gha-creds-file.json',
'',
@@ -196,7 +196,7 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add both entries since they don't actually exist as gitignore rules
expect(content).toContain('.qwen/');
expect(content).toContain('.gemini/');
expect(content).toContain('gha-creds-*.json');
// Verify the entries were added (not just mentioned in comments)
@@ -204,9 +204,9 @@ describe('updateGitignore', () => {
.split('\n')
.map((line) => line.split('#')[0].trim())
.filter((line) => line);
expect(lines).toContain('.qwen/');
expect(lines).toContain('.gemini/');
expect(lines).toContain('gha-creds-*.json');
expect(lines).toContain('my-app.qwen/config');
expect(lines).toContain('my-app.gemini/config');
expect(lines).toContain('some-other-gha-creds-file.json');
});

View File

@@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { t } from '../../i18n/index.js';
export const GITHUB_WORKFLOW_PATHS = [
'qwen-dispatch/qwen-dispatch.yml',
'qwen-assistant/qwen-invoke.yml',
'issue-triage/qwen-triage.yml',
'issue-triage/qwen-scheduled-triage.yml',
'pr-review/qwen-review.yml',
'gemini-dispatch/gemini-dispatch.yml',
'gemini-assistant/gemini-invoke.yml',
'issue-triage/gemini-triage.yml',
'issue-triage/gemini-scheduled-triage.yml',
'pr-review/gemini-review.yml',
];
// Generate OS-specific commands to open the GitHub pages needed for setup.
@@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
return commands;
}
// Add Qwen Code specific entries to .gitignore file
// Add Gemini CLI specific entries to .gitignore file
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
const gitignoreEntries = ['.qwen/', 'gha-creds-*.json'];
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
const gitignorePath = path.join(gitRepoRoot, '.gitignore');
try {
@@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = {
// Get the latest release tag from GitHub
const proxy = context?.services?.config?.getProxy();
const releaseTag = await getLatestGitHubRelease(proxy);
const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`;
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
// Create the .github/workflows directory to download the files into
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
@@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = {
for (const workflow of GITHUB_WORKFLOW_PATHS) {
downloads.push(
(async () => {
const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
@@ -204,9 +204,8 @@ export const setupGithubCommand: SlashCommand = {
toolName: 'run_shell_command',
toolArgs: {
description:
'Setting up GitHub Actions to triage issues and review PRs with Qwen.',
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
command,
is_background: false,
},
};
},

View File

@@ -64,6 +64,8 @@ export interface CommandContext {
* @param history The array of history items to load.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;

View File

@@ -120,6 +120,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
},
branchName: 'main',
debugMessage: '',
corgiMode: false,
errorCount: 0,
nightly: false,
isTrustedFolder: true,
@@ -182,7 +183,6 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState, settings);
// Smoke check that the Footer renders when enabled.
expect(lastFrame()).toContain('Footer');
});
@@ -200,6 +200,7 @@ describe('Composer', () => {
it('passes correct props to Footer including vim mode when enabled', async () => {
const uiState = createMockUIState({
branchName: 'feature-branch',
corgiMode: true,
errorCount: 2,
sessionStats: {
sessionId: 'test-session',

View File

@@ -33,6 +33,7 @@ export const Footer: React.FC = () => {
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
@@ -44,6 +45,7 @@ export const Footer: React.FC = () => {
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@@ -151,6 +153,16 @@ export const Footer: React.FC = () => {
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center" paddingLeft={2}>
{corgiMode && (
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>

View File

@@ -15,7 +15,6 @@ import type {
} from '@qwen-code/qwen-code-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -23,9 +22,7 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({
}));
describe('<HistoryItemDisplay />', () => {
const mockConfig = {
getChatRecordingService: () => undefined,
} as unknown as Config;
const mockConfig = {} as unknown as Config;
const baseItem = {
id: 1,
timestamp: 12345,
@@ -136,11 +133,9 @@ describe('<HistoryItemDisplay />', () => {
duration: '1s',
};
const { lastFrame } = renderWithProviders(
<ConfigContext.Provider value={mockConfig as never}>
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>
</ConfigContext.Provider>,
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
});

View File

@@ -1307,7 +1307,7 @@ describe('InputPrompt', () => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,

View File

@@ -707,20 +707,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
statusText = t('Accepting edits');
}
const borderColor =
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
return (
<>
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={borderColor}
borderStyle="round"
borderColor={
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default
}
paddingX={1}
>
<Text
@@ -834,10 +829,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}

View File

@@ -9,7 +9,6 @@ import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
@@ -21,36 +20,20 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (
metrics: SessionMetrics,
sessionId: string = 'test-session-id-12345',
promptCount: number = 5,
chatRecordingEnabled: boolean = true,
) => {
const renderWithMockedStats = (metrics: SessionMetrics) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId,
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount,
promptCount: 5,
},
getPromptCount: () => promptCount,
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
const mockConfig = {
getChatRecordingService: vi.fn(() =>
chatRecordingEnabled ? ({} as never) : undefined,
),
};
return render(
<ConfigContext.Provider value={mockConfig as never}>
<SessionSummaryDisplay duration="1h 23m 45s" />
</ConfigContext.Provider>,
);
return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
};
describe('<SessionSummaryDisplay />', () => {
@@ -87,68 +70,6 @@ describe('<SessionSummaryDisplay />', () => {
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).toContain('To continue this session, run');
expect(output).toContain('qwen --resume test-session-id-12345');
expect(output).toMatchSnapshot();
});
it('does not show resume message when there are no messages', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
// Pass promptCount = 0 to simulate no messages
const { lastFrame } = renderWithMockedStats(
metrics,
'test-session-id-12345',
0,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('To continue this session, run');
expect(output).not.toContain('qwen --resume');
});
it('does not show resume message when chat recording is disabled', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(
metrics,
'test-session-id-12345',
5,
false,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('To continue this session, run');
expect(output).not.toContain('qwen --resume');
});
});

View File

@@ -5,11 +5,7 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
interface SessionSummaryDisplayProps {
@@ -18,31 +14,9 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => {
const config = useConfig();
const { stats } = useSessionStats();
// Only show the resume message if there were messages in the session AND
// chat recording is enabled (otherwise there is nothing to resume).
const hasMessages = stats.promptCount > 0;
const canResume = !!config.getChatRecordingService();
return (
<>
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
{hasMessages && canResume && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('To continue this session, run')}{' '}
<Text color={theme.text.accent}>
qwen --resume {stats.sessionId}
</Text>
</Text>
</Box>
)}
</>
);
};
}) => (
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
);

View File

@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
context: {
fileFiltering: {
respectGitIgnore: false,
respectQwenIgnore: true,
respectQwemIgnore: true,
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
loadMemoryFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwenIgnore: false,
respectQwemIgnore: false,
enableRecursiveFileSearch: false,
disableFuzzySearch: false,
},

View File

@@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
"──────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
──────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
"──────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
──────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
"──────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
! Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
"──────────────────────────────────────────────────────────────────────────────────────────────────
! Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
* Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
"──────────────────────────────────────────────────────────────────────────────────────────────────
* Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
"──────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
`;

View File

@@ -6,7 +6,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ Agent powering down. Goodbye! │
│ │
│ Interaction Summary │
│ Session ID: test-session-id-12345
│ Session ID:
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ Code Changes: +42 -15 │
@@ -26,7 +26,5 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
To continue this session, run qwen --resume test-session-id-12345"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false* │
│ │
│ ▼ │
│ │
│ │
@@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title true* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips true* │
│ │
│ ▼ │
│ │
│ │

View File

@@ -54,6 +54,7 @@ export interface UIState {
qwenAuthState: QwenAuthState;
editorError: string | null;
isEditorDialogOpen: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;

View File

@@ -153,6 +153,7 @@ describe('useSlashCommandProcessor', () => {
openModelDialog: mockOpenModelDialog,
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
toggleCorgiMode: vi.fn(),
},
),
);
@@ -908,6 +909,7 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openSettingsDialog
vi.fn(), // openModelSelectionDialog

View File

@@ -68,6 +68,7 @@ interface SlashCommandProcessorActions {
openApprovalModeDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
openSubagentCreateDialog: () => void;
@@ -205,6 +206,7 @@ export const useSlashCommandProcessor = (
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,
toggleCorgiMode: actions.toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,

View File

@@ -15,23 +15,6 @@ import {
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
useAttentionNotifications,
} from './useAttentionNotifications.js';
import type { LoadedSettings } from '../../config/settings.js';
const mockSettings: LoadedSettings = {
merged: {
general: {
terminalBell: true,
},
},
} as LoadedSettings;
const mockSettingsDisabled: LoadedSettings = {
merged: {
general: {
terminalBell: false,
},
},
} as LoadedSettings;
vi.mock('../../utils/attentionNotification.js', () => ({
notifyTerminalAttention: vi.fn(),
@@ -57,7 +40,6 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
...props,
},
},
@@ -71,13 +53,11 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: true },
);
});
@@ -92,7 +72,6 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettings,
},
});
@@ -107,7 +86,6 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
settings: mockSettings,
},
});
@@ -116,13 +94,11 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete,
{ enabled: true },
);
});
@@ -134,7 +110,6 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
settings: mockSettings,
},
});
@@ -143,7 +118,6 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
@@ -161,7 +135,6 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: 5,
settings: mockSettings,
},
});
@@ -170,30 +143,9 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).not.toHaveBeenCalled();
});
it('does not notify when terminalBell setting is disabled', () => {
const { rerender } = render({
settings: mockSettingsDisabled,
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettingsDisabled,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: false },
);
});
});

View File

@@ -10,7 +10,6 @@ import {
notifyTerminalAttention,
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
import type { LoadedSettings } from '../../config/settings.js';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
@@ -18,16 +17,13 @@ interface UseAttentionNotificationsOptions {
isFocused: boolean;
streamingState: StreamingState;
elapsedTime: number;
settings: LoadedSettings;
}
export const useAttentionNotifications = ({
isFocused,
streamingState,
elapsedTime,
settings,
}: UseAttentionNotificationsOptions) => {
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
@@ -37,16 +33,14 @@ export const useAttentionNotifications = ({
!isFocused &&
!awaitingNotificationSentRef.current
) {
notifyTerminalAttention(AttentionNotificationReason.ToolApproval, {
enabled: terminalBellEnabled,
});
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
awaitingNotificationSentRef.current = true;
}
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
awaitingNotificationSentRef.current = false;
}
}, [isFocused, streamingState, terminalBellEnabled]);
}, [isFocused, streamingState]);
useEffect(() => {
if (streamingState === StreamingState.Responding) {
@@ -59,13 +53,11 @@ export const useAttentionNotifications = ({
respondingElapsedRef.current >=
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
if (wasLongTask && !isFocused) {
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, {
enabled: terminalBellEnabled,
});
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
return;
}
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
}, [streamingState, elapsedTime, isFocused]);
};

View File

@@ -20,6 +20,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
loadHistory: (_newHistory) => {},
pendingItem: null,
setPendingItem: (_item) => {},
toggleCorgiMode: () => {},
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},
reloadCommands: () => {},

View File

@@ -13,7 +13,6 @@ export enum AttentionNotificationReason {
export interface TerminalNotificationOptions {
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
enabled?: boolean;
}
const TERMINAL_BELL = '\u0007';
@@ -29,11 +28,6 @@ export function notifyTerminalAttention(
_reason: AttentionNotificationReason,
options: TerminalNotificationOptions = {},
): boolean {
// Check if terminal bell is enabled (default true for backwards compatibility)
if (options.enabled === false) {
return false;
}
const stream = options.stream ?? process.stdout;
if (!stream?.write || stream.isTTY === false) {
return false;

View File

@@ -58,7 +58,7 @@ export const getLatestGitHubRelease = async (
try {
const controller = new AbortController();
const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`;
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
const response = await fetch(endpoint, {
method: 'GET',
@@ -83,12 +83,9 @@ export const getLatestGitHubRelease = async (
}
return releaseTag;
} catch (_error) {
console.debug(
`Failed to determine latest qwen-code-action release:`,
_error,
);
console.debug(`Failed to determine latest run-gemini-cli release:`, _error);
throw new Error(
`Unable to determine the latest qwen-code-action release on GitHub.`,
`Unable to determine the latest run-gemini-cli release on GitHub.`,
);
}
};

View File

@@ -38,6 +38,7 @@
"src/ui/commands/clearCommand.test.ts",
"src/ui/commands/compressCommand.test.ts",
"src/ui/commands/copyCommand.test.ts",
"src/ui/commands/corgiCommand.test.ts",
"src/ui/commands/docsCommand.test.ts",
"src/ui/commands/editorCommand.test.ts",
"src/ui/commands/extensionsCommand.test.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.5.0",
"version": "0.4.0-preview.1",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -47,7 +47,7 @@
"fast-uri": "^3.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"glob": "^10.5.0",
"glob": "^10.4.5",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
@@ -63,7 +63,7 @@
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
"undici": "^6.22.0",
"undici": "^7.10.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
},

View File

@@ -318,7 +318,6 @@ export interface ConfigParameters {
generationConfig?: Partial<ContentGeneratorConfig>;
cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean;
chatRecording?: boolean;
// Web search providers
webSearch?: {
provider: Array<{
@@ -350,7 +349,6 @@ export interface ConfigParameters {
skipStartupContext?: boolean;
sdkMode?: boolean;
sessionSubagents?: SubagentConfig[];
channel?: string;
}
function normalizeConfigOutputFormat(
@@ -458,7 +456,6 @@ export class Config {
| undefined;
private readonly cliVersion?: string;
private readonly experimentalZedIntegration: boolean = false;
private readonly chatRecordingEnabled: boolean;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly webSearch?: {
provider: Array<{
@@ -488,7 +485,6 @@ export class Config {
private readonly enableToolOutputTruncation: boolean;
private readonly eventEmitter?: EventEmitter;
private readonly useSmartEdit: boolean;
private readonly channel: string | undefined;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId ?? randomUUID();
@@ -574,8 +570,6 @@ export class Config {
._generationConfig as ContentGeneratorConfig;
this.cliVersion = params.cliVersion;
this.chatRecordingEnabled = params.chatRecording ?? true;
this.loadMemoryFromIncludeDirectories =
params.loadMemoryFromIncludeDirectories ?? false;
this.chatCompression = params.chatCompression;
@@ -604,7 +598,6 @@ export class Config {
this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true;
this.useSmartEdit = params.useSmartEdit ?? false;
this.extensionManagement = params.extensionManagement ?? true;
this.channel = params.channel;
this.storage = new Storage(this.targetDir);
this.vlmSwitchMode = params.vlmSwitchMode;
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
@@ -622,9 +615,7 @@ export class Config {
setGlobalDispatcher(new ProxyAgent(this.getProxy() as string));
}
this.geminiClient = new GeminiClient(this);
this.chatRecordingService = this.chatRecordingEnabled
? new ChatRecordingService(this)
: undefined;
this.chatRecordingService = new ChatRecordingService(this);
}
/**
@@ -744,9 +735,7 @@ export class Config {
startNewSession(sessionId?: string): string {
this.sessionId = sessionId ?? randomUUID();
this.sessionData = undefined;
this.chatRecordingService = this.chatRecordingEnabled
? new ChatRecordingService(this)
: undefined;
this.chatRecordingService = new ChatRecordingService(this);
if (this.initialized) {
logStartSession(this, new StartSessionEvent(this));
}
@@ -1155,10 +1144,6 @@ export class Config {
return this.cliVersion;
}
getChannel(): string | undefined {
return this.channel;
}
/**
* Get the current FileSystemService
*/
@@ -1275,10 +1260,7 @@ export class Config {
/**
* Returns the chat recording service.
*/
getChatRecordingService(): ChatRecordingService | undefined {
if (!this.chatRecordingEnabled) {
return undefined;
}
getChatRecordingService(): ChatRecordingService {
if (!this.chatRecordingService) {
this.chatRecordingService = new ChatRecordingService(this);
}

View File

@@ -7,13 +7,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { OpenAIContentConverter } from './converter.js';
import type { StreamingToolCallParser } from './streamingToolCallParser.js';
import {
Type,
type GenerateContentParameters,
type Content,
type Tool,
type CallableTool,
} from '@google/genai';
import type { GenerateContentParameters, Content } from '@google/genai';
import type OpenAI from 'openai';
describe('OpenAIContentConverter', () => {
@@ -208,338 +202,4 @@ describe('OpenAIContentConverter', () => {
);
});
});
describe('convertGeminiToolsToOpenAI', () => {
it('should convert Gemini tools with parameters field', async () => {
const geminiTools = [
{
functionDeclarations: [
{
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: Type.OBJECT,
properties: {
location: { type: Type.STRING },
},
required: ['location'],
},
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'get_weather',
description: 'Get weather for a location',
parameters: {
type: 'object',
properties: {
location: { type: 'string' },
},
required: ['location'],
},
},
});
});
it('should convert MCP tools with parametersJsonSchema field', async () => {
// MCP tools use parametersJsonSchema which contains plain JSON schema (not Gemini types)
const mcpTools = [
{
functionDeclarations: [
{
name: 'read_file',
description: 'Read a file from disk',
parametersJsonSchema: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'function',
function: {
name: 'read_file',
description: 'Read a file from disk',
parameters: {
type: 'object',
properties: {
path: { type: 'string' },
},
required: ['path'],
},
},
});
});
it('should handle CallableTool by resolving tool function', async () => {
const callableTools = [
{
tool: async () => ({
functionDeclarations: [
{
name: 'dynamic_tool',
description: 'A dynamically resolved tool',
parameters: {
type: Type.OBJECT,
properties: {},
},
},
],
}),
},
] as CallableTool[];
const result = await converter.convertGeminiToolsToOpenAI(callableTools);
expect(result).toHaveLength(1);
expect(result[0].function.name).toBe('dynamic_tool');
});
it('should skip functions without name or description', async () => {
const geminiTools = [
{
functionDeclarations: [
{
name: 'valid_tool',
description: 'A valid tool',
},
{
name: 'missing_description',
// no description
},
{
// no name
description: 'Missing name',
},
],
},
] as Tool[];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0].function.name).toBe('valid_tool');
});
it('should handle tools without functionDeclarations', async () => {
const emptyTools: Tool[] = [
{} as Tool,
{ functionDeclarations: [] },
];
const result = await converter.convertGeminiToolsToOpenAI(emptyTools);
expect(result).toHaveLength(0);
});
it('should handle functions without parameters', async () => {
const geminiTools: Tool[] = [
{
functionDeclarations: [
{
name: 'no_params_tool',
description: 'A tool without parameters',
},
],
},
];
const result = await converter.convertGeminiToolsToOpenAI(geminiTools);
expect(result).toHaveLength(1);
expect(result[0].function.parameters).toBeUndefined();
});
it('should not mutate original parametersJsonSchema', async () => {
const originalSchema = {
type: 'object',
properties: { foo: { type: 'string' } },
};
const mcpTools: Tool[] = [
{
functionDeclarations: [
{
name: 'test_tool',
description: 'Test tool',
parametersJsonSchema: originalSchema,
},
],
} as Tool,
];
const result = await converter.convertGeminiToolsToOpenAI(mcpTools);
// Verify the result is a copy, not the same reference
expect(result[0].function.parameters).not.toBe(originalSchema);
expect(result[0].function.parameters).toEqual(originalSchema);
});
});
describe('convertGeminiToolParametersToOpenAI', () => {
it('should convert type names to lowercase', () => {
const params = {
type: 'OBJECT',
properties: {
count: { type: 'INTEGER' },
amount: { type: 'NUMBER' },
name: { type: 'STRING' },
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
expect(result).toEqual({
type: 'object',
properties: {
count: { type: 'integer' },
amount: { type: 'number' },
name: { type: 'string' },
},
});
});
it('should convert string numeric constraints to numbers', () => {
const params = {
type: 'object',
properties: {
value: {
type: 'number',
minimum: '0',
maximum: '100',
multipleOf: '0.5',
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
expect(properties?.['value']).toEqual({
type: 'number',
minimum: 0,
maximum: 100,
multipleOf: 0.5,
});
});
it('should convert string length constraints to integers', () => {
const params = {
type: 'object',
properties: {
text: {
type: 'string',
minLength: '1',
maxLength: '100',
},
items: {
type: 'array',
minItems: '0',
maxItems: '10',
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
expect(properties?.['text']).toEqual({
type: 'string',
minLength: 1,
maxLength: 100,
});
expect(properties?.['items']).toEqual({
type: 'array',
minItems: 0,
maxItems: 10,
});
});
it('should handle nested objects', () => {
const params = {
type: 'object',
properties: {
nested: {
type: 'object',
properties: {
deep: {
type: 'INTEGER',
minimum: '0',
},
},
},
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
const properties = result?.['properties'] as Record<string, unknown>;
const nested = properties?.['nested'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<string, unknown>;
expect(nestedProperties?.['deep']).toEqual({
type: 'integer',
minimum: 0,
});
});
it('should handle arrays', () => {
const params = {
type: 'array',
items: {
type: 'INTEGER',
},
};
const result = converter.convertGeminiToolParametersToOpenAI(params);
expect(result).toEqual({
type: 'array',
items: {
type: 'integer',
},
});
});
it('should return undefined for null or non-object input', () => {
expect(
converter.convertGeminiToolParametersToOpenAI(
null as unknown as Record<string, unknown>,
),
).toBeNull();
expect(
converter.convertGeminiToolParametersToOpenAI(
undefined as unknown as Record<string, unknown>,
),
).toBeUndefined();
});
it('should not mutate the original parameters', () => {
const original = {
type: 'OBJECT',
properties: {
count: { type: 'INTEGER' },
},
};
const originalCopy = JSON.parse(JSON.stringify(original));
converter.convertGeminiToolParametersToOpenAI(original);
expect(original).toEqual(originalCopy);
});
});
});

View File

@@ -193,11 +193,13 @@ export class OpenAIContentConverter {
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
if (func.parametersJsonSchema) {
// MCP tool format - use parametersJsonSchema directly
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
if (func.parametersJsonSchema) {
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
}
} else if (func.parameters) {
// Gemini tool format - convert parameters to OpenAI format
parameters = this.convertGeminiToolParametersToOpenAI(

View File

@@ -130,13 +130,10 @@ export class DashScopeOpenAICompatibleProvider
}
buildMetadata(userPromptId: string): DashScopeRequestMetadata {
const channel = this.cliConfig.getChannel?.();
return {
metadata: {
sessionId: this.cliConfig.getSessionId?.(),
promptId: userPromptId,
...(channel ? { channel } : {}),
},
};
}

View File

@@ -28,6 +28,5 @@ export type DashScopeRequestMetadata = {
metadata: {
sessionId?: string;
promptId: string;
channel?: string;
};
};

View File

@@ -761,6 +761,7 @@ describe('getQwenOAuthClient', () => {
});
it('should load cached credentials if available', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
@@ -768,6 +769,10 @@ describe('getQwenOAuthClient', () => {
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to use cached credentials
const mockTokenManager = {
getValidCredentials: vi.fn().mockResolvedValue(mockCredentials),
@@ -787,6 +792,18 @@ describe('getQwenOAuthClient', () => {
});
it('should handle cached credentials refresh failure', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'expired-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to fail with a specific error
const mockTokenManager = {
getValidCredentials: vi
@@ -816,35 +833,6 @@ describe('getQwenOAuthClient', () => {
SharedTokenManager.getInstance = originalGetInstance;
});
it('should not start device flow when requireCachedCredentials is true', async () => {
// Make SharedTokenManager fail so we hit the fallback path
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No credentials')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
// If requireCachedCredentials is honored, device-flow network requests should not start
vi.mocked(global.fetch).mockResolvedValue({ ok: true } as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig, {
requireCachedCredentials: true,
}),
),
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
expect(global.fetch).not.toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('CredentialsClearRequiredError', () => {
@@ -1586,6 +1574,178 @@ describe('Credential Caching Functions', () => {
expect(updatedCredentials.access_token).toBe('new-token');
});
});
describe('loadCachedQwenCredentials', () => {
it('should load and validate cached credentials successfully', async () => {
const { promises: fs } = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials));
// Test through getQwenOAuthClient which calls loadCachedQwenCredentials
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
// Make SharedTokenManager fail to test the fallback
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock successful auth flow after cache load fails
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email model.completion',
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
expect(fs.readFile).toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle invalid cached credentials gracefully', async () => {
const { promises: fs } = await import('node:fs');
// Mock file read to return invalid JSON
vi.mocked(fs.readFile).mockResolvedValue('invalid-json');
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock auth flow
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-token',
refresh_token: 'new-refresh',
token_type: 'Bearer',
expires_in: 3600,
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle file access errors', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock device flow to fail quickly
const mockAuthResponse = {
ok: true,
json: async () => ({
error: 'invalid_request',
error_description: 'Invalid request parameters',
}),
};
global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response);
// Should proceed to device flow when cache loading fails
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
});
});
describe('Enhanced Error Handling and Edge Cases', () => {

View File

@@ -514,14 +514,26 @@ export async function getQwenOAuthClient(
}
}
// If shared manager fails, check if we have cached credentials for device flow
if (await loadCachedQwenCredentials(client)) {
// We have cached credentials but they might be expired
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
}
return client;
}
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
// interactive device authorization (unless explicitly forbidden above).
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -677,19 +689,6 @@ async function authWithQwenDeviceFlow(
// Cache the new tokens
await cacheQwenCredentials(credentials);
// IMPORTANT:
// SharedTokenManager maintains an in-memory cache and throttles file checks.
// If we only write the creds file here, a subsequent `getQwenOAuthClient()`
// call in the same process (within the throttle window) may not re-read the
// updated file and could incorrectly re-trigger device auth.
// Clearing the cache forces the next call to reload from disk.
try {
SharedTokenManager.getInstance().clearCache();
} catch {
// In unit tests we sometimes mock SharedTokenManager.getInstance() with a
// minimal stub; cache invalidation is best-effort and should not break auth.
}
// Emit auth progress success event
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
@@ -848,6 +847,27 @@ async function authWithQwenDeviceFlow(
}
}
async function loadCachedQwenCredentials(
client: QwenOAuth2Client,
): Promise<boolean> {
try {
const keyFile = getQwenCachedCredentialPath();
const creds = await fs.readFile(keyFile, 'utf-8');
const credentials = JSON.parse(creds) as QwenCredentials;
client.setCredentials(credentials);
// Verify that the credentials are still valid
const { token } = await client.getAccessToken();
if (!token) {
return false;
}
return true;
} catch (_) {
return false;
}
}
async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath();
try {
@@ -893,14 +913,6 @@ export async function clearQwenCredentials(): Promise<void> {
}
// Log other errors but don't throw - clearing credentials should be non-critical
console.warn('Warning: Failed to clear cached Qwen credentials:', error);
} finally {
// Also clear SharedTokenManager in-memory cache to prevent stale credentials
// from being reused within the same process after the file is removed.
try {
SharedTokenManager.getInstance().clearCache();
} catch {
// Best-effort; don't fail credential clearing if SharedTokenManager is mocked.
}
}
}

View File

@@ -58,7 +58,6 @@ export type {
SubAgentStartEvent,
SubAgentRoundEvent,
SubAgentStreamTextEvent,
SubAgentUsageEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentFinishEvent,

View File

@@ -10,7 +10,7 @@ import type {
ToolConfirmationOutcome,
ToolResultDisplay,
} from '../tools/tools.js';
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
import type { Part } from '@google/genai';
export type SubAgentEvent =
| 'start'
@@ -20,7 +20,6 @@ export type SubAgentEvent =
| 'tool_call'
| 'tool_result'
| 'tool_waiting_approval'
| 'usage_metadata'
| 'finish'
| 'error';
@@ -32,7 +31,6 @@ export enum SubAgentEventType {
TOOL_CALL = 'tool_call',
TOOL_RESULT = 'tool_result',
TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
USAGE_METADATA = 'usage_metadata',
FINISH = 'finish',
ERROR = 'error',
}
@@ -59,14 +57,6 @@ export interface SubAgentStreamTextEvent {
timestamp: number;
}
export interface SubAgentUsageEvent {
subagentId: string;
round: number;
usage: GenerateContentResponseUsageMetadata;
durationMs?: number;
timestamp: number;
}
export interface SubAgentToolCallEvent {
subagentId: string;
round: number;

View File

@@ -50,15 +50,6 @@ describe('SubagentStatistics', () => {
expect(summary.outputTokens).toBe(600);
expect(summary.totalTokens).toBe(1800);
});
it('should track thought and cached tokens', () => {
stats.recordTokens(100, 50, 10, 5);
const summary = stats.getSummary();
expect(summary.thoughtTokens).toBe(10);
expect(summary.cachedTokens).toBe(5);
expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5
});
});
describe('tool usage statistics', () => {
@@ -102,14 +93,14 @@ describe('SubagentStatistics', () => {
stats.start(baseTime);
stats.setRounds(2);
stats.recordToolCall('file_read', true, 100);
stats.recordTokens(1000, 500, 20, 10);
stats.recordTokens(1000, 500);
const result = stats.formatCompact('Test task', baseTime + 5000);
expect(result).toContain('📋 Task Completed: Test task');
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)');
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)');
});
it('should handle zero tool calls', () => {

View File

@@ -23,8 +23,6 @@ export interface SubagentStatsSummary {
successRate: number;
inputTokens: number;
outputTokens: number;
thoughtTokens: number;
cachedTokens: number;
totalTokens: number;
estimatedCost: number;
toolUsage: ToolUsageStats[];
@@ -38,8 +36,6 @@ export class SubagentStatistics {
private failedToolCalls = 0;
private inputTokens = 0;
private outputTokens = 0;
private thoughtTokens = 0;
private cachedTokens = 0;
private toolUsage = new Map<string, ToolUsageStats>();
start(now = Date.now()) {
@@ -78,16 +74,9 @@ export class SubagentStatistics {
this.toolUsage.set(name, tu);
}
recordTokens(
input: number,
output: number,
thought: number = 0,
cached: number = 0,
) {
recordTokens(input: number, output: number) {
this.inputTokens += Math.max(0, input || 0);
this.outputTokens += Math.max(0, output || 0);
this.thoughtTokens += Math.max(0, thought || 0);
this.cachedTokens += Math.max(0, cached || 0);
}
getSummary(now = Date.now()): SubagentStatsSummary {
@@ -97,11 +86,7 @@ export class SubagentStatistics {
totalToolCalls > 0
? (this.successfulToolCalls / totalToolCalls) * 100
: 0;
const totalTokens =
this.inputTokens +
this.outputTokens +
this.thoughtTokens +
this.cachedTokens;
const totalTokens = this.inputTokens + this.outputTokens;
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
return {
rounds: this.rounds,
@@ -112,8 +97,6 @@ export class SubagentStatistics {
successRate,
inputTokens: this.inputTokens,
outputTokens: this.outputTokens,
thoughtTokens: this.thoughtTokens,
cachedTokens: this.cachedTokens,
totalTokens,
estimatedCost,
toolUsage: Array.from(this.toolUsage.values()),
@@ -133,12 +116,8 @@ export class SubagentStatistics {
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
];
if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`,
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
);
}
return lines.join('\n');
@@ -173,12 +152,8 @@ export class SubagentStatistics {
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
);
if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`,
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
);
}
if (stats.toolUsage && stats.toolUsage.length) {

View File

@@ -69,8 +69,6 @@ async function createMockConfig(
targetDir: '.',
debugMode: false,
cwd: process.cwd(),
// Avoid writing any chat recording records from tests (e.g. via tool-call telemetry).
chatRecording: false,
};
const config = new Config(configParams);
await config.initialize();

View File

@@ -41,7 +41,6 @@ import type {
SubAgentToolResultEvent,
SubAgentStreamTextEvent,
SubAgentErrorEvent,
SubAgentUsageEvent,
} from './subagent-events.js';
import {
type SubAgentEventEmitter,
@@ -370,7 +369,6 @@ export class SubAgentScope {
},
};
const roundStreamStart = Date.now();
const responseStream = await chat.sendMessageStream(
this.modelConfig.model ||
this.runtimeContext.getModel() ||
@@ -441,19 +439,10 @@ export class SubAgentScope {
if (lastUsage) {
const inTok = Number(lastUsage.promptTokenCount || 0);
const outTok = Number(lastUsage.candidatesTokenCount || 0);
const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0);
const cachedTok = Number(lastUsage.cachedContentTokenCount || 0);
if (
isFinite(inTok) ||
isFinite(outTok) ||
isFinite(thoughtTok) ||
isFinite(cachedTok)
) {
if (isFinite(inTok) || isFinite(outTok)) {
this.stats.recordTokens(
isFinite(inTok) ? inTok : 0,
isFinite(outTok) ? outTok : 0,
isFinite(thoughtTok) ? thoughtTok : 0,
isFinite(cachedTok) ? cachedTok : 0,
);
// mirror legacy fields for compatibility
this.executionStats.inputTokens =
@@ -464,20 +453,11 @@ export class SubAgentScope {
(isFinite(outTok) ? outTok : 0);
this.executionStats.totalTokens =
(this.executionStats.inputTokens || 0) +
(this.executionStats.outputTokens || 0) +
(isFinite(thoughtTok) ? thoughtTok : 0) +
(isFinite(cachedTok) ? cachedTok : 0);
(this.executionStats.outputTokens || 0);
this.executionStats.estimatedCost =
(this.executionStats.inputTokens || 0) * 3e-5 +
(this.executionStats.outputTokens || 0) * 6e-5;
}
this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, {
subagentId: this.subagentId,
round: turnCounter,
usage: lastUsage,
durationMs: Date.now() - roundStreamStart,
timestamp: Date.now(),
} as SubAgentUsageEvent);
}
if (functionCalls.length > 0) {

View File

@@ -249,9 +249,6 @@ export class QwenLogger {
authType === AuthType.USE_OPENAI
? this.config?.getContentGeneratorConfig().baseUrl || ''
: '',
...(this.config?.getChannel?.()
? { channel: this.config.getChannel() }
: {}),
},
_v: `qwen-code@${version}`,
} as RumPayload;

View File

@@ -23,12 +23,6 @@ export type UiEvent =
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
export {
EVENT_API_ERROR,
EVENT_API_RESPONSE,
EVENT_TOOL_CALL,
} from './constants.js';
export interface ToolCallStats {
count: number;
success: number;

View File

@@ -198,52 +198,6 @@ describe('GlobTool', () => {
);
});
it('should find files even if workspace path casing differs from glob results (Windows/macOS)', async () => {
// Only relevant for Windows and macOS
if (process.platform !== 'win32' && process.platform !== 'darwin') {
return;
}
let mismatchedRootDir = tempRootDir;
if (process.platform === 'win32') {
// 1. Create a path with mismatched casing for the workspace root
// e.g., if tempRootDir is "C:\Users\...", make it "c:\Users\..."
const drive = path.parse(tempRootDir).root;
if (!drive || !drive.match(/^[A-Z]:\\/)) {
// Skip if we can't determine/manipulate the drive letter easily
return;
}
const lowerDrive = drive.toLowerCase();
mismatchedRootDir = lowerDrive + tempRootDir.substring(drive.length);
} else {
// macOS: change the casing of the path
if (tempRootDir === tempRootDir.toLowerCase()) {
mismatchedRootDir = tempRootDir.toUpperCase();
} else {
mismatchedRootDir = tempRootDir.toLowerCase();
}
}
// 2. Create a new GlobTool instance with this mismatched root
const mismatchedConfig = {
...mockConfig,
getTargetDir: () => mismatchedRootDir,
getWorkspaceContext: () =>
createMockWorkspaceContext(mismatchedRootDir),
} as unknown as Config;
const mismatchedGlobTool = new GlobTool(mismatchedConfig);
// 3. Execute search
const params: GlobToolParams = { pattern: '*.txt' };
const invocation = mismatchedGlobTool.build(params);
const result = await invocation.execute(abortSignal);
expect(result.llmContent).toContain('Found 2 file(s)');
});
it('should return error if path is outside workspace', async () => {
// Bypassing validation to test execute method directly
vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null);

View File

@@ -134,21 +134,12 @@ class GlobToolInvocation extends BaseToolInvocation<
this.getFileFilteringOptions(),
);
const normalizePathForComparison = (p: string) =>
process.platform === 'win32' || process.platform === 'darwin'
? p.toLowerCase()
: p;
const filteredAbsolutePaths = new Set(
filteredPaths.map((p) =>
normalizePathForComparison(
path.resolve(this.config.getTargetDir(), p),
),
),
filteredPaths.map((p) => path.resolve(this.config.getTargetDir(), p)),
);
const filteredEntries = allEntries.filter((entry) =>
filteredAbsolutePaths.has(normalizePathForComparison(entry.fullpath())),
filteredAbsolutePaths.has(entry.fullpath()),
);
if (!filteredEntries || filteredEntries.length === 0) {

View File

@@ -391,19 +391,6 @@ describe('Shell Command Processor - Encoding Functions', () => {
expect(result).toBe('windows-1252');
});
it('should prioritize UTF-8 detection over Windows system encoding', () => {
mockedOsPlatform.mockReturnValue('win32');
mockedExecSync.mockReturnValue('Active code page: 936'); // GBK
const buffer = Buffer.from('test');
// Mock chardet to return UTF-8
mockedChardetDetect.mockReturnValue('UTF-8');
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('utf-8');
});
it('should cache null system encoding result', () => {
// Reset the cache specifically for this test
resetEncodingCache();

View File

@@ -34,15 +34,6 @@ export function getCachedEncodingForBuffer(buffer: Buffer): string {
// If we have a cached system encoding, use it
if (cachedSystemEncoding) {
// If the system encoding is not UTF-8 (e.g. Windows CP936), but the buffer
// is detected as UTF-8, prefer UTF-8. This handles tools like 'git' which
// often output UTF-8 regardless of the system code page.
if (cachedSystemEncoding !== 'utf-8') {
const detected = detectEncodingFromBuffer(buffer);
if (detected === 'utf-8') {
return 'utf-8';
}
}
return cachedSystemEncoding;
}

View File

@@ -59,7 +59,7 @@ Creates a new query session with the Qwen Code.
| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. |
| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. |
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 30 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
@@ -76,12 +76,12 @@ Creates a new query session with the Qwen Code.
The SDK enforces the following default timeouts:
| Timeout | Default | Description |
| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. |
| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. |
| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. |
| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. |
| Timeout | Default | Description |
| ---------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `canUseTool` | 30 seconds | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. |
| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. |
| `controlRequest` | 30 seconds | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. |
| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. |
You can customize these timeouts via the `timeout` option:

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/sdk",
"version": "0.5.0",
"version": "0.4.0-preview.1",
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",

View File

@@ -5,9 +5,9 @@
* Implements AsyncIterator protocol for message consumption.
*/
const DEFAULT_CAN_USE_TOOL_TIMEOUT = 60_000;
const DEFAULT_CAN_USE_TOOL_TIMEOUT = 30_000;
const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000;
const DEFAULT_CONTROL_REQUEST_TIMEOUT = 60_000;
const DEFAULT_CONTROL_REQUEST_TIMEOUT = 30_000;
const DEFAULT_STREAM_CLOSE_TIMEOUT = 60_000;
import { randomUUID } from 'node:crypto';
@@ -434,9 +434,8 @@ export class Query implements AsyncIterable<SDKMessage> {
try {
const canUseToolTimeout =
this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT;
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
setTimeout(
() => reject(new Error('Permission callback timeout')),
canUseToolTimeout,
);
@@ -452,10 +451,6 @@ export class Query implements AsyncIterable<SDKMessage> {
timeoutPromise,
]);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (result.behavior === 'allow') {
return {
behavior: 'allow',
@@ -794,20 +789,14 @@ export class Query implements AsyncIterable<SDKMessage> {
) {
const streamCloseTimeout =
this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT;
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(() => {
logger.info('streamCloseTimeout resolved');
resolve();
}, streamCloseTimeout);
});
await Promise.race([this.firstResultReceivedPromise, timeoutPromise]);
if (timeoutId) {
clearTimeout(timeoutId);
}
await Promise.race([
this.firstResultReceivedPromise,
new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, streamCloseTimeout);
}),
]);
}
this.endInput();

View File

@@ -139,7 +139,6 @@ export class ProcessTransport implements Transport {
'stream-json',
'--output-format',
'stream-json',
'--channel=SDK',
];
if (this.options.model) {

View File

@@ -316,7 +316,7 @@ export interface QueryOptions {
/**
* Logging level for the SDK.
* Controls the verbosity of log messages output by the SDK.
* @default 'error'
* @default 'info'
*/
logLevel?: 'debug' | 'info' | 'warn' | 'error';

View File

@@ -22,7 +22,7 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
export class SdkLogger {
private static config: LoggerConfig = {};
private static effectiveLevel: LogLevel = 'error';
private static effectiveLevel: LogLevel = 'info';
static configure(config: LoggerConfig): void {
this.config = config;
@@ -47,7 +47,7 @@ export class SdkLogger {
return 'debug';
}
return 'error';
return 'info';
}
private static isValidLogLevel(level: string): boolean {

View File

@@ -542,16 +542,13 @@ describe('Query', () => {
const canUseTool = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ behavior: 'allow' }), 15000);
setTimeout(() => resolve({ behavior: 'allow' }), 35000); // Exceeds 30s timeout
}),
);
const query = new Query(transport, {
cwd: '/test',
canUseTool,
timeout: {
canUseTool: 10000,
},
});
const controlReq = createControlRequest('can_use_tool', 'perm-req-4');
@@ -570,7 +567,7 @@ describe('Query', () => {
});
}
},
{ timeout: 15000 },
{ timeout: 35000 },
);
await query.close();
@@ -1207,12 +1204,7 @@ describe('Query', () => {
});
it('should handle control request timeout', async () => {
const query = new Query(transport, {
cwd: '/test',
timeout: {
controlRequest: 10000,
},
});
const query = new Query(transport, { cwd: '/test' });
// Respond to initialize
await vi.waitFor(() => {
@@ -1232,7 +1224,7 @@ describe('Query', () => {
await expect(interruptPromise).rejects.toThrow(/timeout/i);
await query.close();
}, 15000);
}, 35000);
it('should handle malformed control responses', async () => {
const query = new Query(transport, { cwd: '/test' });

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.5.0",
"version": "0.4.0-preview.1",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -1,6 +1,5 @@
**
!dist/
!dist/**
../
../../
!LICENSE

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