mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-06 08:59:15 +00:00
Compare commits
257 Commits
release/v0
...
v0.5.1-nig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e79909b09 | ||
|
|
52faa0da5c | ||
|
|
87f1dd9061 | ||
|
|
d4a0e84508 | ||
|
|
1806390421 | ||
|
|
5c6ed070de | ||
|
|
883c6d878c | ||
|
|
b9a2cc7bdf | ||
|
|
4cbb57a793 | ||
|
|
9280739a85 | ||
|
|
b6128ef2d2 | ||
|
|
f7ef720e3b | ||
|
|
0e6ebe85e4 | ||
|
|
3138aa1fe3 | ||
|
|
0f2f1faee5 | ||
|
|
641dd03689 | ||
|
|
44fef93399 | ||
|
|
b073b2db79 | ||
|
|
ccc6192164 | ||
|
|
a5f8c66c35 | ||
|
|
c8d18591b0 | ||
|
|
e9036daa8d | ||
|
|
8e29cc88f9 | ||
|
|
61ce586117 | ||
|
|
90fc4c33f0 | ||
|
|
389d8dd9c4 | ||
|
|
4590138a1e | ||
|
|
0ac191e2db | ||
|
|
9e392b3035 | ||
|
|
65796e2799 | ||
|
|
8fdcd53f53 | ||
|
|
693a58d517 | ||
|
|
c7f4c462f4 | ||
|
|
c94708e448 | ||
|
|
0b854e494f | ||
|
|
f6f4b24356 | ||
|
|
bc5dd87eb4 | ||
|
|
f8aeb06823 | ||
|
|
bca288e742 | ||
|
|
5841370b1a | ||
|
|
0d90d5c118 | ||
|
|
cc0d688c8b | ||
|
|
4eb7aa5448 | ||
|
|
9978fe107b | ||
|
|
b95bb2cd95 | ||
|
|
e895c49f5c | ||
|
|
3191cf73b3 | ||
|
|
f5306339f6 | ||
|
|
026fd468b1 | ||
|
|
d25af87eaf | ||
|
|
a5039d15bf | ||
|
|
3ff916a5f1 | ||
|
|
7bb9bc1e5e | ||
|
|
8b29dd130e | ||
|
|
d0be8b43d7 | ||
|
|
3095442eb3 | ||
|
|
2ceecab503 | ||
|
|
e5ed0334ab | ||
|
|
2b62b1e8bc | ||
|
|
89be6edb5e | ||
|
|
d812c9dcf2 | ||
|
|
d754767e73 | ||
|
|
bb8447edd7 | ||
|
|
02234f5434 | ||
|
|
25261ab88d | ||
|
|
60a58ad8e5 | ||
|
|
c20df192a8 | ||
|
|
b34894c8ea | ||
|
|
ba3b576906 | ||
|
|
b67ee32481 | ||
|
|
5b8ce440ea | ||
|
|
58d3a9c253 | ||
|
|
d06a6d7ef9 | ||
|
|
ae9753a326 | ||
|
|
a02c4b2765 | ||
|
|
0055399cba | ||
|
|
5f78909040 | ||
|
|
5ef3d32f16 | ||
|
|
49c032492a | ||
|
|
4345b9370e | ||
|
|
d2e2a07327 | ||
|
|
5b74422be6 | ||
|
|
06c398a015 | ||
|
|
aec5d6463a | ||
|
|
29032d2c6a | ||
|
|
e91ea3ac1a | ||
|
|
f2a74c74b6 | ||
|
|
21651410c8 | ||
|
|
09cefbcf67 | ||
|
|
5fddcd509c | ||
|
|
d7b9466516 | ||
|
|
fcd4bb9c03 | ||
|
|
828b760820 | ||
|
|
ef3d7b92d0 | ||
|
|
58b9e477bc | ||
|
|
f4edcc5cd2 | ||
|
|
7adb9ed7ff | ||
|
|
f146f062cb | ||
|
|
111234eb24 | ||
|
|
dd7f9ed489 | ||
|
|
5fa87e6fbb | ||
|
|
a6a572336c | ||
|
|
96cd685b1b | ||
|
|
e8b4ee111c | ||
|
|
ac0d5206ba | ||
|
|
e5e1e6a3da | ||
|
|
6269415e7b | ||
|
|
efccd44cb4 | ||
|
|
0a8281f2dd | ||
|
|
efbf50554d | ||
|
|
63e4794633 | ||
|
|
be71976a1f | ||
|
|
e47263f7c9 | ||
|
|
51b4de0c23 | ||
|
|
67eee14ca9 | ||
|
|
ed44520e51 | ||
|
|
7cd26f728d | ||
|
|
ad79b9bcab | ||
|
|
ad301963a6 | ||
|
|
e538a3d1bf | ||
|
|
413c143004 | ||
|
|
57a684ad97 | ||
|
|
541d0b22e5 | ||
|
|
96b275a756 | ||
|
|
be44e7af56 | ||
|
|
ac9cb3a6d3 | ||
|
|
13aa4b03c7 | ||
|
|
75fd2a5dcc | ||
|
|
811b332bc3 | ||
|
|
bf4673b00b | ||
|
|
645a5b181a | ||
|
|
2957058521 | ||
|
|
e7b92622ce | ||
|
|
82f97fe56d | ||
|
|
2c1a836f18 | ||
|
|
3a7b1159ae | ||
|
|
c6f5a4585e | ||
|
|
b1a439e38f | ||
|
|
a6467e7f9b | ||
|
|
5ed60348d6 | ||
|
|
0851ab572d | ||
|
|
8203f6582f | ||
|
|
2d844d11df | ||
|
|
4145f45c7c | ||
|
|
d56923b657 | ||
|
|
32258f2f04 | ||
|
|
5dec3e653c | ||
|
|
3053e6c41f | ||
|
|
86cd06ef43 | ||
|
|
7270983821 | ||
|
|
b1901f103f | ||
|
|
5701a3c897 | ||
|
|
2145b28f8b | ||
|
|
e3c456a430 | ||
|
|
35f98723ca | ||
|
|
b9b3b6d62e | ||
|
|
cec6b8691a | ||
|
|
05f5189bb4 | ||
|
|
c6299bf135 | ||
|
|
2e449f4d45 | ||
|
|
90fc53a9df | ||
|
|
ed0d5f67db | ||
|
|
1b37d729cb | ||
|
|
1acc24bc17 | ||
|
|
b1e74e5732 | ||
|
|
82205034cc | ||
|
|
c038745897 | ||
|
|
6885138cf0 | ||
|
|
9ae45c01a6 | ||
|
|
5ce40085d5 | ||
|
|
627f5fb43a | ||
|
|
9cc48f12da | ||
|
|
dc340daf8b | ||
|
|
f78b1eff93 | ||
|
|
8bc9bea5a1 | ||
|
|
b986692f94 | ||
|
|
4f63d92bb1 | ||
|
|
3c09ad46ca | ||
|
|
d5ede56e62 | ||
|
|
530039c517 | ||
|
|
0cbf95d6b3 | ||
|
|
579772197a | ||
|
|
934365c41f | ||
|
|
f623bfbb34 | ||
|
|
f503eb2520 | ||
|
|
3cf22c065f | ||
|
|
a1ec1227cc | ||
|
|
36af718616 | ||
|
|
795e7fa2c5 | ||
|
|
b6914c6b33 | ||
|
|
f11d054a47 | ||
|
|
4ad377b0d8 | ||
|
|
b7f9acf0ff | ||
|
|
4dfbdcddca | ||
|
|
826516581b | ||
|
|
4f964b5281 | ||
|
|
de8ea0678d | ||
|
|
c4bcd178a4 | ||
|
|
e5729b0420 | ||
|
|
aceb857436 | ||
|
|
e15dd2f5c9 | ||
|
|
8ac38aad92 | ||
|
|
38fd303b07 | ||
|
|
9899d872a2 | ||
|
|
36a96a7b5c | ||
|
|
951f6b2829 | ||
|
|
eff01819a8 | ||
|
|
31f8ca07b6 | ||
|
|
39adaaff11 | ||
|
|
fd2e5b0933 | ||
|
|
49a2be195d | ||
|
|
ce07fb2b3f | ||
|
|
e2beecb9c4 | ||
|
|
ecc6e22002 | ||
|
|
99f93b457c | ||
|
|
748ad8f4dd | ||
|
|
a33187ed7a | ||
|
|
088c766c22 | ||
|
|
b82ef5b73f | ||
|
|
328924f578 | ||
|
|
1eedd36542 | ||
|
|
9ba99177b9 | ||
|
|
7d2411e72f | ||
|
|
5a9f5e3432 | ||
|
|
95b67bbebd | ||
|
|
492c56a780 | ||
|
|
06a8580361 | ||
|
|
dcc10eb0a9 | ||
|
|
805e5f92c1 | ||
|
|
8cb7ea0d3d | ||
|
|
b534bd2b18 | ||
|
|
6286b8b6e8 | ||
|
|
e81255e589 | ||
|
|
018990b7f6 | ||
|
|
bc2b503e8d | ||
|
|
454cbfdde4 | ||
|
|
04dfad7ab5 | ||
|
|
e02866d06f | ||
|
|
9fcdd3fa77 | ||
|
|
754ae30939 | ||
|
|
0577fe6f36 | ||
|
|
732220e651 | ||
|
|
729a3d0ab3 | ||
|
|
0e3759fbd2 | ||
|
|
f8db157a5d | ||
|
|
f827aadd76 | ||
|
|
39426be9a1 | ||
|
|
f95f6e63bb | ||
|
|
91af599823 | ||
|
|
ad8d7aae8a | ||
|
|
d22d07a840 | ||
|
|
28892996b3 | ||
|
|
eeeb1d490a | ||
|
|
247c237647 | ||
|
|
c423e12aa7 | ||
|
|
dc40995e70 | ||
|
|
1123b9f0fc |
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -27,7 +27,7 @@
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "npm: build: vscode-ide-companion"
|
||||
"preLaunchTask": "launch: vscode-ide-companion (copy+build)"
|
||||
},
|
||||
{
|
||||
"name": "Attach",
|
||||
|
||||
16
.vscode/tasks.json
vendored
16
.vscode/tasks.json
vendored
@@ -20,6 +20,22 @@
|
||||
"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,6 +88,12 @@ 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,19 +10,21 @@ 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]
|
||||
/language ui [zh-CN|en-US|ru-RU]
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -31,6 +33,7 @@ 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
|
||||
|
||||
|
||||
534
docs/mcp-example-configs.md
Normal file
534
docs/mcp-example-configs.md
Normal file
@@ -0,0 +1,534 @@
|
||||
# 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).
|
||||
418
docs/mcp-quick-start.md
Normal file
418
docs/mcp-quick-start.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# 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.
|
||||
382
docs/mcp-testing-validation.md
Normal file
382
docs/mcp-testing-validation.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# 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,3 +54,7 @@ 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,6 +75,8 @@ 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'],
|
||||
@@ -111,10 +113,14 @@ export default tseslint.config(
|
||||
{
|
||||
allow: [
|
||||
'react-dom/test-utils',
|
||||
'react-dom/client',
|
||||
'memfs/lib/volume.js',
|
||||
'yargs/**',
|
||||
'msw/node',
|
||||
'**/generated/**'
|
||||
'**/generated/**',
|
||||
'./styles/tailwind.css',
|
||||
'./styles/App.css',
|
||||
'./styles/style.css'
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,8 +13,6 @@ 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';
|
||||
@@ -25,6 +23,14 @@ 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?: {
|
||||
@@ -39,6 +45,9 @@ type SessionUpdateNotification = {
|
||||
text?: string;
|
||||
};
|
||||
modeId?: string;
|
||||
_meta?: {
|
||||
usage?: UsageMetadata;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -86,10 +95,14 @@ function setupAcpTest(
|
||||
const permissionHandler =
|
||||
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
|
||||
|
||||
const agent = spawn('node', [rig.bundlePath, '--experimental-acp'], {
|
||||
cwd: rig.testDir!,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
const agent = spawn(
|
||||
'node',
|
||||
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
|
||||
{
|
||||
cwd: rig.testDir!,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
|
||||
agent.stderr?.on('data', (chunk) => {
|
||||
stderr.push(chunk.toString());
|
||||
@@ -253,11 +266,11 @@ function setupAcpTest(
|
||||
}
|
||||
|
||||
(IS_SANDBOX ? describe.skip : describe)('acp integration', () => {
|
||||
it('creates, lists, loads, and resumes a session', async () => {
|
||||
it('basic smoke test', async () => {
|
||||
const rig = new TestRig();
|
||||
rig.setup('acp load session');
|
||||
|
||||
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
|
||||
const { sendRequest, cleanup, stderr } = setupAcpTest(rig);
|
||||
|
||||
try {
|
||||
const initResult = await sendRequest('initialize', {
|
||||
@@ -283,34 +296,6 @@ 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(''));
|
||||
@@ -587,4 +572,52 @@ 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,12 +438,8 @@ describe('Configuration Options (E2E)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
// Skip - qwen-oauth requires user interaction which is not possible in CI environments
|
||||
it.skip('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
|
||||
|
||||
@@ -73,15 +73,26 @@ export class SDKTestHelper {
|
||||
await mkdir(this.testDir, { recursive: true });
|
||||
|
||||
// Optionally create .qwen/settings.json for CLI configuration
|
||||
if (options.createQwenConfig) {
|
||||
if (options.createQwenConfig !== false) {
|
||||
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
|
||||
},
|
||||
...options.settings,
|
||||
general: {
|
||||
...generalSettings,
|
||||
chatRecording: false, // SDK tests don't need chat recording
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
|
||||
@@ -31,9 +31,7 @@ describe('Tool Control Parameters (E2E)', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
helper = new SDKTestHelper();
|
||||
testDir = await helper.setup('tool-control', {
|
||||
createQwenConfig: false,
|
||||
});
|
||||
testDir = await helper.setup('tool-control');
|
||||
});
|
||||
|
||||
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
|
||||
? extraInitialArgs
|
||||
: [this.bundlePath, ...extraInitialArgs];
|
||||
? ['--no-chat-recording', ...extraInitialArgs]
|
||||
: [this.bundlePath, '--no-chat-recording', ...extraInitialArgs];
|
||||
return { command, initialArgs };
|
||||
}
|
||||
|
||||
|
||||
1252
package-lock.json
generated
1252
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.4.0",
|
||||
"version": "0.5.1-nightly.20251216.52faa0da",
|
||||
"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.4.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1-nightly.20251216.52faa0da"
|
||||
},
|
||||
"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.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"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.4.0",
|
||||
"version": "0.5.1-nightly.20251216.52faa0da",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -33,7 +33,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.1-nightly.20251216.52faa0da"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "^6.2.3",
|
||||
"ink-gradient": "^3.0.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"string-width": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"tar": "^7.5.1",
|
||||
"tar": "^7.5.2",
|
||||
"undici": "^7.10.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
|
||||
@@ -88,6 +88,16 @@ 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
|
||||
*
|
||||
@@ -241,9 +251,11 @@ class Connection {
|
||||
).toResult();
|
||||
}
|
||||
|
||||
let errorName;
|
||||
let details;
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorName = error.name;
|
||||
details = error.message;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
@@ -254,6 +266,10 @@ class Connection {
|
||||
details = error.message;
|
||||
}
|
||||
|
||||
if (errorName === 'TokenManagerError') {
|
||||
return RequestError.authRequired(details).toResult();
|
||||
}
|
||||
|
||||
return RequestError.internalError(details).toResult();
|
||||
}
|
||||
}
|
||||
@@ -357,6 +373,7 @@ 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,15 +6,19 @@
|
||||
|
||||
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';
|
||||
@@ -123,13 +127,33 @@ 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();
|
||||
await this.config.refreshAuth(method);
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
async newSession({
|
||||
@@ -268,14 +292,17 @@ class GeminiAgent {
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = this.settings.merged.security?.auth?.selectedType;
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
throw acp.RequestError.authRequired('No Selected Type');
|
||||
}
|
||||
|
||||
try {
|
||||
await config.refreshAuth(selectedType);
|
||||
// Use true for the second argument to ensure only cached credentials are used
|
||||
await config.refreshAuth(selectedType, true);
|
||||
} catch (e) {
|
||||
console.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
throw acp.RequestError.authRequired(
|
||||
'Authentication failed: ' + (e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ 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',
|
||||
};
|
||||
@@ -57,8 +58,6 @@ 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>;
|
||||
@@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({
|
||||
methodId: z.string(),
|
||||
});
|
||||
|
||||
export const authenticateResponseSchema = z.null();
|
||||
export const authenticateUpdateSchema = z.object({
|
||||
_meta: z.object({
|
||||
authUri: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
|
||||
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
@@ -316,6 +321,23 @@ 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,
|
||||
});
|
||||
@@ -500,10 +522,12 @@ 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(),
|
||||
@@ -536,7 +560,6 @@ export const sessionUpdateSchema = z.union([
|
||||
|
||||
export const agentResponseSchema = z.union([
|
||||
initializeResponseSchema,
|
||||
authenticateResponseSchema,
|
||||
newSessionResponseSchema,
|
||||
loadSessionResponseSchema,
|
||||
promptResponseSchema,
|
||||
|
||||
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
59
packages/cli/src/acp-integration/service/filesystem.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @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,6 +30,20 @@ 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,4 +411,48 @@ 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,8 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
} from '@google/genai';
|
||||
import type { SessionContext } from './types.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
@@ -52,6 +55,9 @@ export class HistoryReplayer {
|
||||
if (record.message) {
|
||||
await this.replayContent(record.message, 'assistant');
|
||||
}
|
||||
if (record.usageMetadata) {
|
||||
await this.replayUsageMetadata(record.usageMetadata);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
@@ -88,11 +94,22 @@ 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.
|
||||
*/
|
||||
@@ -118,6 +135,54 @@ 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,7 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, FunctionCall, Part } from '@google/genai';
|
||||
import type {
|
||||
Content,
|
||||
FunctionCall,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
Part,
|
||||
} from '@google/genai';
|
||||
import type {
|
||||
Config,
|
||||
GeminiChat,
|
||||
@@ -55,6 +60,7 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -79,6 +85,7 @@ 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;
|
||||
@@ -96,6 +103,7 @@ 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 {
|
||||
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
@@ -251,6 +259,15 @@ 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[] = [];
|
||||
|
||||
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO
|
||||
? await invocation.shouldConfirmExecute(abortSignal)
|
||||
: false;
|
||||
|
||||
if (confirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
@@ -522,6 +541,7 @@ 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: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'read_file',
|
||||
content: [],
|
||||
locations: [],
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
SubAgentUsageEvent,
|
||||
ToolCallConfirmationDetails,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
@@ -20,6 +21,7 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
||||
*/
|
||||
export class SubAgentTracker {
|
||||
private readonly toolCallEmitter: ToolCallEmitter;
|
||||
private readonly messageEmitter: MessageEmitter;
|
||||
private readonly toolStates = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -76,6 +79,7 @@ export class SubAgentTracker {
|
||||
private readonly client: acp.Client,
|
||||
) {
|
||||
this.toolCallEmitter = new ToolCallEmitter(ctx);
|
||||
this.messageEmitter = new MessageEmitter(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,16 +96,19 @@ 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();
|
||||
},
|
||||
@@ -252,6 +259,20 @@ 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,4 +148,59 @@ 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,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,16 @@ 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.
|
||||
*/
|
||||
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an agent thought chunk.
|
||||
* Emits usage metadata.
|
||||
*/
|
||||
async emitAgentThought(text: string): Promise<void> {
|
||||
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 };
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text },
|
||||
_meta: meta,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
|
||||
expect(sendUpdateSpy).toHaveBeenCalledWith({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'call-123',
|
||||
status: 'in_progress',
|
||||
status: 'pending',
|
||||
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: 'in_progress',
|
||||
status: 'pending',
|
||||
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: 'in_progress',
|
||||
status: 'pending',
|
||||
title: 'failing_tool', // Fallback to tool name
|
||||
content: [],
|
||||
locations: [], // Fallback to empty
|
||||
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '{"output":"test output"}',
|
||||
text: 'test output',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'content',
|
||||
content: { type: 'text', text: '{"output":"Function output"}' },
|
||||
content: { type: 'text', text: 'Function output' },
|
||||
},
|
||||
],
|
||||
rawOutput: 'raw result',
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: params.callId,
|
||||
status: 'in_progress',
|
||||
status: params.status || 'pending',
|
||||
title,
|
||||
content: [],
|
||||
locations,
|
||||
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
|
||||
// Handle functionResponse parts - stringify the response
|
||||
if ('functionResponse' in part && part.functionResponse) {
|
||||
try {
|
||||
const responseText = JSON.stringify(part.functionResponse.response);
|
||||
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);
|
||||
result.push({
|
||||
type: 'content',
|
||||
content: { type: 'text', text: responseText },
|
||||
|
||||
@@ -35,6 +35,8 @@ 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,6 +130,11 @@ 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 */
|
||||
@@ -138,6 +143,7 @@ export interface CliArgs {
|
||||
coreTools: string[] | undefined;
|
||||
excludeTools: string[] | undefined;
|
||||
authType: string | undefined;
|
||||
channel: string | undefined;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
@@ -232,6 +238,11 @@ 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', {
|
||||
@@ -297,6 +308,11 @@ 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,
|
||||
@@ -559,6 +575,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -983,6 +1005,12 @@ 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,8 +191,29 @@ 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,6 +485,8 @@ 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' | string; // Allow custom language codes
|
||||
export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes
|
||||
|
||||
// State
|
||||
let currentLanguage: SupportedLanguage = 'en';
|
||||
@@ -51,10 +51,12 @@ 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,6 +867,7 @@ 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:',
|
||||
|
||||
1121
packages/cli/src/i18n/locales/ru.js
Normal file
1121
packages/cli/src/i18n/locales/ru.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -820,6 +820,7 @@ export default {
|
||||
// Exit Screen / Stats
|
||||
// ============================================================================
|
||||
'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!',
|
||||
'To continue this session, run': '要继续此会话,请运行',
|
||||
'Interaction Summary': '交互摘要',
|
||||
'Session ID:': '会话 ID:',
|
||||
'Tool Calls:': '工具调用:',
|
||||
|
||||
@@ -58,7 +58,6 @@ 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,7 +15,6 @@ 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';
|
||||
@@ -63,7 +62,6 @@ 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,7 +136,6 @@ 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
|
||||
@@ -485,7 +484,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}, 100);
|
||||
},
|
||||
setDebugMessage,
|
||||
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
@@ -498,7 +496,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
openSettingsDialog,
|
||||
openModelDialog,
|
||||
setDebugMessage,
|
||||
setCorgiMode,
|
||||
dispatchExtensionStateUpdate,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
@@ -945,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isFocused,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
settings,
|
||||
});
|
||||
|
||||
// Dialog close functionality
|
||||
@@ -1218,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
@@ -1309,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
qwenAuthState,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
corgiMode,
|
||||
debugMessage,
|
||||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* @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.');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @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();
|
||||
},
|
||||
};
|
||||
587
packages/cli/src/ui/commands/languageCommand.test.ts
Normal file
587
packages/cli/src/ui/commands/languageCommand.test.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* @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,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null {
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
|
||||
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
|
||||
// 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);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
@@ -127,16 +128,17 @@ async function setUiLanguage(
|
||||
context.ui.reloadCommands();
|
||||
|
||||
// Map language codes to friendly display names
|
||||
const langDisplayNames: Record<SupportedLanguage, string> = {
|
||||
const langDisplayNames: Partial<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: langDisplayNames[lang] || lang,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = {
|
||||
: t('LLM output language not set'),
|
||||
'',
|
||||
t('Available subcommands:'),
|
||||
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
|
||||
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
|
||||
` /language output <language> - ${t('Set LLM output language')}`,
|
||||
].join('\n');
|
||||
|
||||
@@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = {
|
||||
const subcommand = parts[0].toLowerCase();
|
||||
|
||||
if (subcommand === 'ui') {
|
||||
// Handle /language ui [zh-CN|en-US]
|
||||
// Handle /language ui [zh-CN|en-US|ru-RU]
|
||||
if (parts.length === 1) {
|
||||
// Show UI language subcommand help
|
||||
return {
|
||||
@@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = {
|
||||
content: [
|
||||
t('Set UI language'),
|
||||
'',
|
||||
t('Usage: /language ui [zh-CN|en-US]'),
|
||||
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
|
||||
'',
|
||||
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.',
|
||||
@@ -266,11 +269,18 @@ 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'),
|
||||
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,13 +317,20 @@ 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] - ' + t('Set UI language'),
|
||||
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
|
||||
' - /language output <language> - ' + t('Set LLM output language'),
|
||||
].join('\n'),
|
||||
};
|
||||
@@ -423,6 +440,29 @@ 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/google-github-actions/run-gemini-cli`,
|
||||
`fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`,
|
||||
];
|
||||
|
||||
for (const substring of expectedSubstrings) {
|
||||
@@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => {
|
||||
|
||||
if (gitignoreExists) {
|
||||
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
|
||||
expect(gitignoreContent).toContain('.gemini/');
|
||||
expect(gitignoreContent).toContain('.qwen/');
|
||||
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('.gemini/\ngha-creds-*.json\n');
|
||||
expect(content).toBe('.qwen/\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.gemini/\ngha-creds-*.json\n',
|
||||
'# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add duplicate entries', async () => {
|
||||
const gitignorePath = path.join(scratchDir, '.gitignore');
|
||||
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
|
||||
const existingContent = '.qwen/\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 = '.gemini/\nsome-other-file\n';
|
||||
const existingContent = '.qwen/\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('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
|
||||
expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n');
|
||||
expect(content).toContain('gha-creds-*.json');
|
||||
// Should not duplicate .gemini/ entry
|
||||
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
|
||||
// Should not duplicate .qwen/ entry
|
||||
expect((content.match(/\.qwen\//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 .gemini/ folder',
|
||||
'my-app.gemini/config',
|
||||
'# This is a comment mentioning .qwen/ folder',
|
||||
'my-app.qwen/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('.gemini/');
|
||||
expect(content).toContain('.qwen/');
|
||||
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('.gemini/');
|
||||
expect(lines).toContain('.qwen/');
|
||||
expect(lines).toContain('gha-creds-*.json');
|
||||
expect(lines).toContain('my-app.gemini/config');
|
||||
expect(lines).toContain('my-app.qwen/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 = [
|
||||
'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',
|
||||
'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',
|
||||
];
|
||||
|
||||
// Generate OS-specific commands to open the GitHub pages needed for setup.
|
||||
@@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Add Gemini CLI specific entries to .gitignore file
|
||||
// Add Qwen Code specific entries to .gitignore file
|
||||
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
|
||||
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
|
||||
const gitignoreEntries = ['.qwen/', '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/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
|
||||
const readmeUrl = `https://github.com/QwenLM/qwen-code-action/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/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
@@ -204,8 +204,9 @@ export const setupGithubCommand: SlashCommand = {
|
||||
toolName: 'run_shell_command',
|
||||
toolArgs: {
|
||||
description:
|
||||
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
|
||||
'Setting up GitHub Actions to triage issues and review PRs with Qwen.',
|
||||
command,
|
||||
is_background: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -64,8 +64,6 @@ 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,7 +120,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
},
|
||||
branchName: 'main',
|
||||
debugMessage: '',
|
||||
corgiMode: false,
|
||||
errorCount: 0,
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
@@ -183,6 +182,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
// Smoke check that the Footer renders when enabled.
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
@@ -200,7 +200,6 @@ 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,7 +33,6 @@ export const Footer: React.FC = () => {
|
||||
debugMode,
|
||||
branchName,
|
||||
debugMessage,
|
||||
corgiMode,
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
promptTokenCount,
|
||||
@@ -45,7 +44,6 @@ 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,
|
||||
@@ -153,16 +151,6 @@ 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,6 +15,7 @@ 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', () => ({
|
||||
@@ -22,7 +23,9 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({
|
||||
}));
|
||||
|
||||
describe('<HistoryItemDisplay />', () => {
|
||||
const mockConfig = {} as unknown as Config;
|
||||
const mockConfig = {
|
||||
getChatRecordingService: () => undefined,
|
||||
} as unknown as Config;
|
||||
const baseItem = {
|
||||
id: 1,
|
||||
timestamp: 12345,
|
||||
@@ -133,9 +136,11 @@ describe('<HistoryItemDisplay />', () => {
|
||||
duration: '1s',
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<SessionStatsProvider>
|
||||
<HistoryItemDisplay {...baseItem} item={item} />
|
||||
</SessionStatsProvider>,
|
||||
<ConfigContext.Provider value={mockConfig as never}>
|
||||
<SessionStatsProvider>
|
||||
<HistoryItemDisplay {...baseItem} item={item} />
|
||||
</SessionStatsProvider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
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, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
|
||||
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
|
||||
@@ -707,15 +707,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
statusText = t('Accepting edits');
|
||||
}
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default
|
||||
}
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
@@ -829,9 +834,10 @@ 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(' ') : ' '}
|
||||
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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>();
|
||||
@@ -20,20 +21,36 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = (metrics: SessionMetrics) => {
|
||||
const renderWithMockedStats = (
|
||||
metrics: SessionMetrics,
|
||||
sessionId: string = 'test-session-id-12345',
|
||||
promptCount: number = 5,
|
||||
chatRecordingEnabled: boolean = true,
|
||||
) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionId,
|
||||
sessionStartTime: new Date(),
|
||||
metrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
promptCount,
|
||||
},
|
||||
|
||||
getPromptCount: () => 5,
|
||||
getPromptCount: () => promptCount,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
|
||||
const mockConfig = {
|
||||
getChatRecordingService: vi.fn(() =>
|
||||
chatRecordingEnabled ? ({} as never) : undefined,
|
||||
),
|
||||
};
|
||||
|
||||
return render(
|
||||
<ConfigContext.Provider value={mockConfig as never}>
|
||||
<SessionSummaryDisplay duration="1h 23m 45s" />
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<SessionSummaryDisplay />', () => {
|
||||
@@ -70,6 +87,68 @@ 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,7 +5,11 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -14,9 +18,31 @@ interface SessionSummaryDisplayProps {
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
}) => (
|
||||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
|
||||
context: {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
respectQwemIgnore: true,
|
||||
respectQwenIgnore: true,
|
||||
enableRecursiveFileSearch: false,
|
||||
disableFuzzySearch: true,
|
||||
},
|
||||
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
|
||||
loadMemoryFromIncludeDirectories: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
respectQwemIgnore: false,
|
||||
respectQwenIgnore: 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: │
|
||||
│ Session ID: test-session-id-12345 │
|
||||
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
|
||||
│ Success Rate: 0.0% │
|
||||
│ Code Changes: +42 -15 │
|
||||
@@ -26,5 +26,7 @@ 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,7 +54,6 @@ export interface UIState {
|
||||
qwenAuthState: QwenAuthState;
|
||||
editorError: string | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
corgiMode: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
|
||||
@@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
openModelDialog: mockOpenModelDialog,
|
||||
quit: mockSetQuittingMessages,
|
||||
setDebugMessage: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => {
|
||||
vi.fn(), // openThemeDialog
|
||||
mockOpenAuthDialog,
|
||||
vi.fn(), // openEditorDialog
|
||||
vi.fn(), // toggleCorgiMode
|
||||
mockSetQuittingMessages,
|
||||
vi.fn(), // openSettingsDialog
|
||||
vi.fn(), // openModelSelectionDialog
|
||||
|
||||
@@ -68,7 +68,6 @@ interface SlashCommandProcessorActions {
|
||||
openApprovalModeDialog: () => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
toggleCorgiMode: () => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
openSubagentCreateDialog: () => void;
|
||||
@@ -206,7 +205,6 @@ export const useSlashCommandProcessor = (
|
||||
setDebugMessage: actions.setDebugMessage,
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
toggleCorgiMode: actions.toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
setGeminiMdFileCount,
|
||||
reloadCommands,
|
||||
|
||||
@@ -15,6 +15,23 @@ 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(),
|
||||
@@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
...props,
|
||||
},
|
||||
},
|
||||
@@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.ToolApproval,
|
||||
{ enabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedNotify).toHaveBeenCalledWith(
|
||||
AttentionNotificationReason.LongTaskComplete,
|
||||
{ enabled: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
elapsedTime: 0,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => {
|
||||
isFocused: false,
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 5,
|
||||
settings: mockSettings,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -143,9 +170,30 @@ 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,6 +10,7 @@ import {
|
||||
notifyTerminalAttention,
|
||||
AttentionNotificationReason,
|
||||
} from '../../utils/attentionNotification.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
|
||||
|
||||
@@ -17,13 +18,16 @@ 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);
|
||||
|
||||
@@ -33,14 +37,16 @@ export const useAttentionNotifications = ({
|
||||
!isFocused &&
|
||||
!awaitingNotificationSentRef.current
|
||||
) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
|
||||
notifyTerminalAttention(AttentionNotificationReason.ToolApproval, {
|
||||
enabled: terminalBellEnabled,
|
||||
});
|
||||
awaitingNotificationSentRef.current = true;
|
||||
}
|
||||
|
||||
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
|
||||
awaitingNotificationSentRef.current = false;
|
||||
}
|
||||
}, [isFocused, streamingState]);
|
||||
}, [isFocused, streamingState, terminalBellEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
@@ -53,11 +59,13 @@ export const useAttentionNotifications = ({
|
||||
respondingElapsedRef.current >=
|
||||
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
|
||||
if (wasLongTask && !isFocused) {
|
||||
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
|
||||
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, {
|
||||
enabled: terminalBellEnabled,
|
||||
});
|
||||
}
|
||||
// Reset tracking for next task
|
||||
respondingElapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
}, [streamingState, elapsedTime, isFocused]);
|
||||
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
loadHistory: (_newHistory) => {},
|
||||
pendingItem: null,
|
||||
setPendingItem: (_item) => {},
|
||||
toggleCorgiMode: () => {},
|
||||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum AttentionNotificationReason {
|
||||
|
||||
export interface TerminalNotificationOptions {
|
||||
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const TERMINAL_BELL = '\u0007';
|
||||
@@ -28,6 +29,11 @@ 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/google-github-actions/run-gemini-cli/releases/latest`;
|
||||
const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
@@ -83,9 +83,12 @@ export const getLatestGitHubRelease = async (
|
||||
}
|
||||
return releaseTag;
|
||||
} catch (_error) {
|
||||
console.debug(`Failed to determine latest run-gemini-cli release:`, _error);
|
||||
console.debug(
|
||||
`Failed to determine latest qwen-code-action release:`,
|
||||
_error,
|
||||
);
|
||||
throw new Error(
|
||||
`Unable to determine the latest run-gemini-cli release on GitHub.`,
|
||||
`Unable to determine the latest qwen-code-action release on GitHub.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"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.4.0",
|
||||
"version": "0.5.1-nightly.20251216.52faa0da",
|
||||
"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.4.5",
|
||||
"glob": "^10.5.0",
|
||||
"google-auth-library": "^9.11.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
|
||||
@@ -318,6 +318,7 @@ export interface ConfigParameters {
|
||||
generationConfig?: Partial<ContentGeneratorConfig>;
|
||||
cliVersion?: string;
|
||||
loadMemoryFromIncludeDirectories?: boolean;
|
||||
chatRecording?: boolean;
|
||||
// Web search providers
|
||||
webSearch?: {
|
||||
provider: Array<{
|
||||
@@ -349,6 +350,7 @@ export interface ConfigParameters {
|
||||
skipStartupContext?: boolean;
|
||||
sdkMode?: boolean;
|
||||
sessionSubagents?: SubagentConfig[];
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigOutputFormat(
|
||||
@@ -456,6 +458,7 @@ 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<{
|
||||
@@ -485,6 +488,7 @@ 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();
|
||||
@@ -570,6 +574,8 @@ export class Config {
|
||||
._generationConfig as ContentGeneratorConfig;
|
||||
this.cliVersion = params.cliVersion;
|
||||
|
||||
this.chatRecordingEnabled = params.chatRecording ?? true;
|
||||
|
||||
this.loadMemoryFromIncludeDirectories =
|
||||
params.loadMemoryFromIncludeDirectories ?? false;
|
||||
this.chatCompression = params.chatCompression;
|
||||
@@ -598,6 +604,7 @@ 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;
|
||||
@@ -615,7 +622,9 @@ export class Config {
|
||||
setGlobalDispatcher(new ProxyAgent(this.getProxy() as string));
|
||||
}
|
||||
this.geminiClient = new GeminiClient(this);
|
||||
this.chatRecordingService = new ChatRecordingService(this);
|
||||
this.chatRecordingService = this.chatRecordingEnabled
|
||||
? new ChatRecordingService(this)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -735,7 +744,9 @@ export class Config {
|
||||
startNewSession(sessionId?: string): string {
|
||||
this.sessionId = sessionId ?? randomUUID();
|
||||
this.sessionData = undefined;
|
||||
this.chatRecordingService = new ChatRecordingService(this);
|
||||
this.chatRecordingService = this.chatRecordingEnabled
|
||||
? new ChatRecordingService(this)
|
||||
: undefined;
|
||||
if (this.initialized) {
|
||||
logStartSession(this, new StartSessionEvent(this));
|
||||
}
|
||||
@@ -1144,6 +1155,10 @@ export class Config {
|
||||
return this.cliVersion;
|
||||
}
|
||||
|
||||
getChannel(): string | undefined {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current FileSystemService
|
||||
*/
|
||||
@@ -1260,7 +1275,10 @@ export class Config {
|
||||
/**
|
||||
* Returns the chat recording service.
|
||||
*/
|
||||
getChatRecordingService(): ChatRecordingService {
|
||||
getChatRecordingService(): ChatRecordingService | undefined {
|
||||
if (!this.chatRecordingEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
if (!this.chatRecordingService) {
|
||||
this.chatRecordingService = new ChatRecordingService(this);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { OpenAIContentConverter } from './converter.js';
|
||||
import type { StreamingToolCallParser } from './streamingToolCallParser.js';
|
||||
import type { GenerateContentParameters, Content } from '@google/genai';
|
||||
import {
|
||||
Type,
|
||||
type GenerateContentParameters,
|
||||
type Content,
|
||||
type Tool,
|
||||
type CallableTool,
|
||||
} from '@google/genai';
|
||||
import type OpenAI from 'openai';
|
||||
|
||||
describe('OpenAIContentConverter', () => {
|
||||
@@ -202,4 +208,338 @@ 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,13 +193,11 @@ export class OpenAIContentConverter {
|
||||
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
|
||||
if (func.parametersJsonSchema) {
|
||||
// MCP tool format - use parametersJsonSchema directly
|
||||
if (func.parametersJsonSchema) {
|
||||
// Create a shallow copy to avoid mutating the original object
|
||||
const paramsCopy = {
|
||||
...(func.parametersJsonSchema as Record<string, unknown>),
|
||||
};
|
||||
parameters = paramsCopy;
|
||||
}
|
||||
// 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,10 +130,13 @@ export class DashScopeOpenAICompatibleProvider
|
||||
}
|
||||
|
||||
buildMetadata(userPromptId: string): DashScopeRequestMetadata {
|
||||
const channel = this.cliConfig.getChannel?.();
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
sessionId: this.cliConfig.getSessionId?.(),
|
||||
promptId: userPromptId,
|
||||
...(channel ? { channel } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,5 +28,6 @@ export type DashScopeRequestMetadata = {
|
||||
metadata: {
|
||||
sessionId?: string;
|
||||
promptId: string;
|
||||
channel?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -761,7 +761,6 @@ 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',
|
||||
@@ -769,10 +768,6 @@ 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),
|
||||
@@ -792,18 +787,6 @@ 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
|
||||
@@ -833,6 +816,35 @@ 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', () => {
|
||||
@@ -1574,178 +1586,6 @@ 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,26 +514,14 @@ 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
|
||||
@@ -689,6 +677,19 @@ 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,
|
||||
@@ -847,27 +848,6 @@ 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 {
|
||||
@@ -913,6 +893,14 @@ 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,6 +58,7 @@ export type {
|
||||
SubAgentStartEvent,
|
||||
SubAgentRoundEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentUsageEvent,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentFinishEvent,
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
|
||||
export type SubAgentEvent =
|
||||
| 'start'
|
||||
@@ -20,6 +20,7 @@ export type SubAgentEvent =
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'tool_waiting_approval'
|
||||
| 'usage_metadata'
|
||||
| 'finish'
|
||||
| 'error';
|
||||
|
||||
@@ -31,6 +32,7 @@ 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',
|
||||
}
|
||||
@@ -57,6 +59,14 @@ 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,6 +50,15 @@ 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', () => {
|
||||
@@ -93,14 +102,14 @@ describe('SubagentStatistics', () => {
|
||||
stats.start(baseTime);
|
||||
stats.setRounds(2);
|
||||
stats.recordToolCall('file_read', true, 100);
|
||||
stats.recordTokens(1000, 500);
|
||||
stats.recordTokens(1000, 500, 20, 10);
|
||||
|
||||
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,500 (in 1000, out 500)');
|
||||
expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)');
|
||||
});
|
||||
|
||||
it('should handle zero tool calls', () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface SubagentStatsSummary {
|
||||
successRate: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thoughtTokens: number;
|
||||
cachedTokens: number;
|
||||
totalTokens: number;
|
||||
estimatedCost: number;
|
||||
toolUsage: ToolUsageStats[];
|
||||
@@ -36,6 +38,8 @@ 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()) {
|
||||
@@ -74,9 +78,16 @@ export class SubagentStatistics {
|
||||
this.toolUsage.set(name, tu);
|
||||
}
|
||||
|
||||
recordTokens(input: number, output: number) {
|
||||
recordTokens(
|
||||
input: number,
|
||||
output: number,
|
||||
thought: number = 0,
|
||||
cached: number = 0,
|
||||
) {
|
||||
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 {
|
||||
@@ -86,7 +97,11 @@ export class SubagentStatistics {
|
||||
totalToolCalls > 0
|
||||
? (this.successfulToolCalls / totalToolCalls) * 100
|
||||
: 0;
|
||||
const totalTokens = this.inputTokens + this.outputTokens;
|
||||
const totalTokens =
|
||||
this.inputTokens +
|
||||
this.outputTokens +
|
||||
this.thoughtTokens +
|
||||
this.cachedTokens;
|
||||
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
|
||||
return {
|
||||
rounds: this.rounds,
|
||||
@@ -97,6 +112,8 @@ export class SubagentStatistics {
|
||||
successRate,
|
||||
inputTokens: this.inputTokens,
|
||||
outputTokens: this.outputTokens,
|
||||
thoughtTokens: this.thoughtTokens,
|
||||
cachedTokens: this.cachedTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
toolUsage: Array.from(this.toolUsage.values()),
|
||||
@@ -116,8 +133,12 @@ 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()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -152,8 +173,12 @@ 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()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
|
||||
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`,
|
||||
);
|
||||
}
|
||||
if (stats.toolUsage && stats.toolUsage.length) {
|
||||
|
||||
@@ -69,6 +69,8 @@ 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,6 +41,7 @@ import type {
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentStreamTextEvent,
|
||||
SubAgentErrorEvent,
|
||||
SubAgentUsageEvent,
|
||||
} from './subagent-events.js';
|
||||
import {
|
||||
type SubAgentEventEmitter,
|
||||
@@ -369,6 +370,7 @@ export class SubAgentScope {
|
||||
},
|
||||
};
|
||||
|
||||
const roundStreamStart = Date.now();
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
this.modelConfig.model ||
|
||||
this.runtimeContext.getModel() ||
|
||||
@@ -439,10 +441,19 @@ export class SubAgentScope {
|
||||
if (lastUsage) {
|
||||
const inTok = Number(lastUsage.promptTokenCount || 0);
|
||||
const outTok = Number(lastUsage.candidatesTokenCount || 0);
|
||||
if (isFinite(inTok) || isFinite(outTok)) {
|
||||
const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0);
|
||||
const cachedTok = Number(lastUsage.cachedContentTokenCount || 0);
|
||||
if (
|
||||
isFinite(inTok) ||
|
||||
isFinite(outTok) ||
|
||||
isFinite(thoughtTok) ||
|
||||
isFinite(cachedTok)
|
||||
) {
|
||||
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 =
|
||||
@@ -453,11 +464,20 @@ export class SubAgentScope {
|
||||
(isFinite(outTok) ? outTok : 0);
|
||||
this.executionStats.totalTokens =
|
||||
(this.executionStats.inputTokens || 0) +
|
||||
(this.executionStats.outputTokens || 0);
|
||||
(this.executionStats.outputTokens || 0) +
|
||||
(isFinite(thoughtTok) ? thoughtTok : 0) +
|
||||
(isFinite(cachedTok) ? cachedTok : 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,6 +249,9 @@ 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,6 +23,12 @@ 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,6 +198,52 @@ 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,12 +134,21 @@ 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) => path.resolve(this.config.getTargetDir(), p)),
|
||||
filteredPaths.map((p) =>
|
||||
normalizePathForComparison(
|
||||
path.resolve(this.config.getTargetDir(), p),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const filteredEntries = allEntries.filter((entry) =>
|
||||
filteredAbsolutePaths.has(entry.fullpath()),
|
||||
filteredAbsolutePaths.has(normalizePathForComparison(entry.fullpath())),
|
||||
);
|
||||
|
||||
if (!filteredEntries || filteredEntries.length === 0) {
|
||||
|
||||
@@ -391,6 +391,19 @@ 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,6 +34,15 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/sdk",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.1-nightly.20251216.52faa0da",
|
||||
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
|
||||
@@ -139,6 +139,7 @@ export class ProcessTransport implements Transport {
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--channel=SDK',
|
||||
];
|
||||
|
||||
if (this.options.model) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.1-nightly.20251216.52faa0da",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
**
|
||||
!dist/
|
||||
!dist/**
|
||||
../
|
||||
../../
|
||||
!LICENSE
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
This file contains third-party software notices and license terms.
|
||||
|
||||
============================================================
|
||||
semver@7.7.2
|
||||
(git+https://github.com/npm/node-semver.git)
|
||||
|
||||
The ISC License
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
@modelcontextprotocol/sdk@1.15.1
|
||||
(git+https://github.com/modelcontextprotocol/typescript-sdk.git)
|
||||
@@ -2317,3 +2338,520 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
============================================================
|
||||
markdown-it@14.1.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
argparse@2.0.1
|
||||
(No repository found)
|
||||
|
||||
A. HISTORY OF THE SOFTWARE
|
||||
==========================
|
||||
|
||||
Python was created in the early 1990s by Guido van Rossum at Stichting
|
||||
Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands
|
||||
as a successor of a language called ABC. Guido remains Python's
|
||||
principal author, although it includes many contributions from others.
|
||||
|
||||
In 1995, Guido continued his work on Python at the Corporation for
|
||||
National Research Initiatives (CNRI, see http://www.cnri.reston.va.us)
|
||||
in Reston, Virginia where he released several versions of the
|
||||
software.
|
||||
|
||||
In May 2000, Guido and the Python core development team moved to
|
||||
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
|
||||
year, the PythonLabs team moved to Digital Creations, which became
|
||||
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
|
||||
https://www.python.org/psf/) was formed, a non-profit organization
|
||||
created specifically to own Python-related Intellectual Property.
|
||||
Zope Corporation was a sponsoring member of the PSF.
|
||||
|
||||
All Python releases are Open Source (see http://www.opensource.org for
|
||||
the Open Source Definition). Historically, most, but not all, Python
|
||||
releases have also been GPL-compatible; the table below summarizes
|
||||
the various releases.
|
||||
|
||||
Release Derived Year Owner GPL-
|
||||
from compatible? (1)
|
||||
|
||||
0.9.0 thru 1.2 1991-1995 CWI yes
|
||||
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
|
||||
1.6 1.5.2 2000 CNRI no
|
||||
2.0 1.6 2000 BeOpen.com no
|
||||
1.6.1 1.6 2001 CNRI yes (2)
|
||||
2.1 2.0+1.6.1 2001 PSF no
|
||||
2.0.1 2.0+1.6.1 2001 PSF yes
|
||||
2.1.1 2.1+2.0.1 2001 PSF yes
|
||||
2.1.2 2.1.1 2002 PSF yes
|
||||
2.1.3 2.1.2 2002 PSF yes
|
||||
2.2 and above 2.1.1 2001-now PSF yes
|
||||
|
||||
Footnotes:
|
||||
|
||||
(1) GPL-compatible doesn't mean that we're distributing Python under
|
||||
the GPL. All Python licenses, unlike the GPL, let you distribute
|
||||
a modified version without making your changes open source. The
|
||||
GPL-compatible licenses make it possible to combine Python with
|
||||
other software that is released under the GPL; the others don't.
|
||||
|
||||
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
|
||||
because its license has a choice of law clause. According to
|
||||
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
|
||||
is "not incompatible" with the GPL.
|
||||
|
||||
Thanks to the many outside volunteers who have worked under Guido's
|
||||
direction to make these releases possible.
|
||||
|
||||
|
||||
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
|
||||
===============================================================
|
||||
|
||||
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
|
||||
--------------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Python Software Foundation
|
||||
("PSF"), and the Individual or Organization ("Licensee") accessing and
|
||||
otherwise using this software ("Python") in source or binary form and
|
||||
its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, PSF hereby
|
||||
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
|
||||
analyze, test, perform and/or display publicly, prepare derivative works,
|
||||
distribute, and otherwise use Python alone or in any derivative version,
|
||||
provided, however, that PSF's License Agreement and PSF's notice of copyright,
|
||||
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
|
||||
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation;
|
||||
All Rights Reserved" are retained in Python alone or in any derivative version
|
||||
prepared by Licensee.
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python.
|
||||
|
||||
4. PSF is making Python available to Licensee on an "AS IS"
|
||||
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. Nothing in this License Agreement shall be deemed to create any
|
||||
relationship of agency, partnership, or joint venture between PSF and
|
||||
Licensee. This License Agreement does not grant permission to use PSF
|
||||
trademarks or trade name in a trademark sense to endorse or promote
|
||||
products or services of Licensee, or any third party.
|
||||
|
||||
8. By copying, installing or otherwise using Python, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
|
||||
|
||||
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
|
||||
-------------------------------------------
|
||||
|
||||
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
|
||||
|
||||
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
|
||||
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
|
||||
Individual or Organization ("Licensee") accessing and otherwise using
|
||||
this software in source or binary form and its associated
|
||||
documentation ("the Software").
|
||||
|
||||
2. Subject to the terms and conditions of this BeOpen Python License
|
||||
Agreement, BeOpen hereby grants Licensee a non-exclusive,
|
||||
royalty-free, world-wide license to reproduce, analyze, test, perform
|
||||
and/or display publicly, prepare derivative works, distribute, and
|
||||
otherwise use the Software alone or in any derivative version,
|
||||
provided, however, that the BeOpen Python License is retained in the
|
||||
Software, alone or in any derivative version prepared by Licensee.
|
||||
|
||||
3. BeOpen is making the Software available to Licensee on an "AS IS"
|
||||
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
|
||||
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
|
||||
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
|
||||
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
5. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
6. This License Agreement shall be governed by and interpreted in all
|
||||
respects by the law of the State of California, excluding conflict of
|
||||
law provisions. Nothing in this License Agreement shall be deemed to
|
||||
create any relationship of agency, partnership, or joint venture
|
||||
between BeOpen and Licensee. This License Agreement does not grant
|
||||
permission to use BeOpen trademarks or trade names in a trademark
|
||||
sense to endorse or promote products or services of Licensee, or any
|
||||
third party. As an exception, the "BeOpen Python" logos available at
|
||||
http://www.pythonlabs.com/logos.html may be used according to the
|
||||
permissions granted on that web page.
|
||||
|
||||
7. By copying, installing or otherwise using the software, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
|
||||
|
||||
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
|
||||
---------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Corporation for National
|
||||
Research Initiatives, having an office at 1895 Preston White Drive,
|
||||
Reston, VA 20191 ("CNRI"), and the Individual or Organization
|
||||
("Licensee") accessing and otherwise using Python 1.6.1 software in
|
||||
source or binary form and its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, CNRI
|
||||
hereby grants Licensee a nonexclusive, royalty-free, world-wide
|
||||
license to reproduce, analyze, test, perform and/or display publicly,
|
||||
prepare derivative works, distribute, and otherwise use Python 1.6.1
|
||||
alone or in any derivative version, provided, however, that CNRI's
|
||||
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
|
||||
1995-2001 Corporation for National Research Initiatives; All Rights
|
||||
Reserved" are retained in Python 1.6.1 alone or in any derivative
|
||||
version prepared by Licensee. Alternately, in lieu of CNRI's License
|
||||
Agreement, Licensee may substitute the following text (omitting the
|
||||
quotes): "Python 1.6.1 is made available subject to the terms and
|
||||
conditions in CNRI's License Agreement. This Agreement together with
|
||||
Python 1.6.1 may be located on the Internet using the following
|
||||
unique, persistent identifier (known as a handle): 1895.22/1013. This
|
||||
Agreement may also be obtained from a proxy server on the Internet
|
||||
using the following URL: http://hdl.handle.net/1895.22/1013".
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python 1.6.1 or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python 1.6.1.
|
||||
|
||||
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
|
||||
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. This License Agreement shall be governed by the federal
|
||||
intellectual property law of the United States, including without
|
||||
limitation the federal copyright law, and, to the extent such
|
||||
U.S. federal law does not apply, by the law of the Commonwealth of
|
||||
Virginia, excluding Virginia's conflict of law provisions.
|
||||
Notwithstanding the foregoing, with regard to derivative works based
|
||||
on Python 1.6.1 that incorporate non-separable material that was
|
||||
previously distributed under the GNU General Public License (GPL), the
|
||||
law of the Commonwealth of Virginia shall govern this License
|
||||
Agreement only as to issues arising under or with respect to
|
||||
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
|
||||
License Agreement shall be deemed to create any relationship of
|
||||
agency, partnership, or joint venture between CNRI and Licensee. This
|
||||
License Agreement does not grant permission to use CNRI trademarks or
|
||||
trade name in a trademark sense to endorse or promote products or
|
||||
services of Licensee, or any third party.
|
||||
|
||||
8. By clicking on the "ACCEPT" button where indicated, or by copying,
|
||||
installing or otherwise using Python 1.6.1, Licensee agrees to be
|
||||
bound by the terms and conditions of this License Agreement.
|
||||
|
||||
ACCEPT
|
||||
|
||||
|
||||
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
|
||||
--------------------------------------------------
|
||||
|
||||
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
|
||||
The Netherlands. All rights reserved.
|
||||
|
||||
Permission to use, copy, modify, and distribute this software and its
|
||||
documentation for any purpose and without fee is hereby granted,
|
||||
provided that the above copyright notice appear in all copies and that
|
||||
both that copyright notice and this permission notice appear in
|
||||
supporting documentation, and that the name of Stichting Mathematisch
|
||||
Centrum or CWI not be used in advertising or publicity pertaining to
|
||||
distribution of the software without specific, written prior
|
||||
permission.
|
||||
|
||||
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
|
||||
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
entities@4.5.0
|
||||
(git://github.com/fb55/entities.git)
|
||||
|
||||
Copyright (c) Felix Böhm
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS,
|
||||
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
============================================================
|
||||
linkify-it@5.0.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2015 Vitaly Puzrin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
uc.micro@2.1.0
|
||||
(No repository found)
|
||||
|
||||
Copyright Mathias Bynens <https://mathiasbynens.be/>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
mdurl@2.0.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
.parse() is based on Joyent's node.js `url` code:
|
||||
|
||||
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
punycode.js@2.3.1
|
||||
(https://github.com/mathiasbynens/punycode.js.git)
|
||||
|
||||
Copyright Mathias Bynens <https://mathiasbynens.be/>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
react@19.1.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
react-dom@19.1.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
scheduler@0.26.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,63 @@ To use this extension, you'll need:
|
||||
- VS Code version 1.101.0 or newer
|
||||
- Qwen Code (installed separately) running within the VS Code integrated terminal
|
||||
|
||||
# Development and Debugging
|
||||
|
||||
To debug and develop this extension locally:
|
||||
|
||||
1. **Clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QwenLM/qwen-code.git
|
||||
cd qwen-code
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or if using pnpm
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Start debugging**
|
||||
|
||||
```bash
|
||||
code . # Open the project root in VS Code
|
||||
```
|
||||
- Open the `packages/vscode-ide-companion/src/extension.ts` file
|
||||
- Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`)
|
||||
- Select **"Launch Companion VS Code Extension"** from the debug dropdown
|
||||
- Press `F5` to launch Extension Development Host
|
||||
|
||||
4. **Make changes and reload**
|
||||
- Edit the source code in the original VS Code window
|
||||
- To see your changes, reload the Extension Development Host window by:
|
||||
- Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS)
|
||||
- Or clicking the "Reload" button in the debug toolbar
|
||||
|
||||
5. **View logs and debug output**
|
||||
- Open the Debug Console in the original VS Code window to see extension logs
|
||||
- In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs
|
||||
|
||||
## Build for Production
|
||||
|
||||
To build the extension for distribution:
|
||||
|
||||
```bash
|
||||
npm run compile
|
||||
# or
|
||||
pnpm run compile
|
||||
```
|
||||
|
||||
To package the extension as a VSIX file:
|
||||
|
||||
```bash
|
||||
npx vsce package
|
||||
# or
|
||||
pnpm vsce package
|
||||
```
|
||||
|
||||
# Terms of Service and Privacy Notice
|
||||
|
||||
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).
|
||||
|
||||
@@ -31,8 +31,69 @@ const esbuildProblemMatcherPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const cssInjectPlugin = {
|
||||
name: 'css-inject',
|
||||
setup(build) {
|
||||
// Handle CSS files
|
||||
build.onLoad({ filter: /\.css$/ }, async (args) => {
|
||||
const fs = await import('fs');
|
||||
const postcss = (await import('postcss')).default;
|
||||
const tailwindcss = (await import('tailwindcss')).default;
|
||||
const autoprefixer = (await import('autoprefixer')).default;
|
||||
|
||||
let css = await fs.promises.readFile(args.path, 'utf8');
|
||||
|
||||
// For styles.css, we need to resolve @import statements
|
||||
if (args.path.endsWith('styles.css')) {
|
||||
// Read all imported CSS files and inline them
|
||||
const importRegex = /@import\s+'([^']+)';/g;
|
||||
let match;
|
||||
const basePath = args.path.substring(0, args.path.lastIndexOf('/'));
|
||||
while ((match = importRegex.exec(css)) !== null) {
|
||||
const importPath = match[1];
|
||||
// Resolve relative paths correctly
|
||||
let fullPath;
|
||||
if (importPath.startsWith('./')) {
|
||||
fullPath = basePath + importPath.substring(1);
|
||||
} else if (importPath.startsWith('../')) {
|
||||
fullPath = basePath + '/' + importPath;
|
||||
} else {
|
||||
fullPath = basePath + '/' + importPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCss = await fs.promises.readFile(fullPath, 'utf8');
|
||||
css = css.replace(match[0], importedCss);
|
||||
} catch (err) {
|
||||
console.warn(`Could not import ${fullPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process with PostCSS (Tailwind + Autoprefixer)
|
||||
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
|
||||
from: args.path,
|
||||
to: args.path,
|
||||
});
|
||||
|
||||
return {
|
||||
contents: `
|
||||
const style = document.createElement('style');
|
||||
style.textContent = ${JSON.stringify(result.css)};
|
||||
document.head.appendChild(style);
|
||||
`,
|
||||
loader: 'js',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context({
|
||||
// Build extension
|
||||
const extensionCtx = await esbuild.context({
|
||||
entryPoints: ['src/extension.ts'],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
@@ -55,11 +116,30 @@ async function main() {
|
||||
],
|
||||
loader: { '.node': 'file' },
|
||||
});
|
||||
|
||||
// Build webview
|
||||
const webviewCtx = await esbuild.context({
|
||||
entryPoints: ['src/webview/index.tsx'],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
sourcesContent: false,
|
||||
platform: 'browser',
|
||||
outfile: 'dist/webview.js',
|
||||
logLevel: 'silent',
|
||||
plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin],
|
||||
jsx: 'automatic', // Use new JSX transform (React 17+)
|
||||
define: {
|
||||
'process.env.NODE_ENV': production ? '"production"' : '"development"',
|
||||
},
|
||||
});
|
||||
|
||||
if (watch) {
|
||||
await ctx.watch();
|
||||
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
|
||||
} else {
|
||||
await ctx.rebuild();
|
||||
await ctx.dispose();
|
||||
await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]);
|
||||
await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,44 @@
|
||||
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'react-hooks': reactHooks,
|
||||
import: importPlugin,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
@@ -30,6 +54,17 @@ export default [
|
||||
format: ['camelCase', 'PascalCase'],
|
||||
},
|
||||
],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
// Restrict deep imports but allow known-safe exceptions used by the webview
|
||||
// - react-dom/client: required for React 18's createRoot API
|
||||
// - ./styles/**: local CSS modules loaded by the webview
|
||||
'import/no-internal-modules': [
|
||||
'error',
|
||||
{
|
||||
allow: ['react-dom/client', './styles/**'],
|
||||
},
|
||||
],
|
||||
|
||||
curly: 'warn',
|
||||
eqeqeq: 'warn',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.1-nightly.20251216.52faa0da",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
@@ -54,6 +54,15 @@
|
||||
{
|
||||
"command": "qwen-code.showNotices",
|
||||
"title": "Qwen Code: View Third-Party Notices"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.openChat",
|
||||
"title": "Qwen Code: Open",
|
||||
"icon": "./assets/icon.png"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"title": "Qwen Code: Login"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -65,6 +74,10 @@
|
||||
{
|
||||
"command": "qwen.diff.cancel",
|
||||
"when": "qwen.diff.isVisible"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"when": "false"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
@@ -77,6 +90,10 @@
|
||||
"command": "qwen.diff.cancel",
|
||||
"when": "qwen.diff.isVisible",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.openChat",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -96,7 +113,7 @@
|
||||
"main": "./dist/extension.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod",
|
||||
"prepackage": "node ./scripts/prepackage.js",
|
||||
"build": "npm run build:dev",
|
||||
"build:dev": "npm run check-types && npm run lint && node esbuild.js",
|
||||
"build:prod": "node esbuild.js --production",
|
||||
@@ -115,21 +132,33 @@
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "20.x",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"npm-run-all2": "^8.0.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "^7.7.2",
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
13
packages/vscode-ide-companion/postcss.config.js
Normal file
13
packages/vscode-ide-companion/postcss.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user