mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-08 18:09:13 +00:00
Compare commits
1 Commits
chore/vsco
...
v0.4.0-pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29d3424d4b |
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -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
16
.vscode/tasks.json
vendored
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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/**'
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'> = [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
1279
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
|
||||
expect.objectContaining({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'pending',
|
||||
status: 'in_progress',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -485,8 +485,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
excludeTools: undefined,
|
||||
authType: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
channel: undefined,
|
||||
chatRecording: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:': '工具调用:',
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
34
packages/cli/src/ui/commands/corgiCommand.test.ts
Normal file
34
packages/cli/src/ui/commands/corgiCommand.test.ts
Normal 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.');
|
||||
});
|
||||
});
|
||||
17
packages/cli/src/ui/commands/corgiCommand.ts
Normal file
17
packages/cli/src/ui/commands/corgiCommand.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
@@ -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'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: 'English(en-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');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!');
|
||||
});
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -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"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -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* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface UIState {
|
||||
qwenAuthState: QwenAuthState;
|
||||
editorError: string | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
corgiMode: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
loadHistory: (_newHistory) => {},
|
||||
pendingItem: null,
|
||||
setPendingItem: (_item) => {},
|
||||
toggleCorgiMode: () => {},
|
||||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,5 @@ export type DashScopeRequestMetadata = {
|
||||
metadata: {
|
||||
sessionId?: string;
|
||||
promptId: string;
|
||||
channel?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ export type {
|
||||
SubAgentStartEvent,
|
||||
SubAgentRoundEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentUsageEvent,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentFinishEvent,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -139,7 +139,6 @@ export class ProcessTransport implements Transport {
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--channel=SDK',
|
||||
];
|
||||
|
||||
if (this.options.model) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
**
|
||||
!dist/
|
||||
!dist/**
|
||||
../
|
||||
../../
|
||||
!LICENSE
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user