Compare commits

...

76 Commits

Author SHA1 Message Date
Mingholy
c9af74816a fix: reset authType settings (#1091)
* fix: reset authType settings

* fix: failed json-output tests

* fix: sandbox exception log to stderr
2025-11-23 17:59:35 +08:00
pomelo
9cfea73207 Merge pull request #1097 from QwenLM/chore-action
fix(ci): remove non-existent label from release failure issue creation
2025-11-23 08:27:34 +08:00
pomelo-nwu
87b1ffe017 fix(ci): remove non-existent label from release failure issue creation 2025-11-22 14:23:49 +08:00
pomelo
83fc321e15 Merge pull request #1090 from QwenLM/feat/logger-enhancement
Improve Usage Statistics by Moving Key Snapshot Fields into Properties
2025-11-21 15:55:26 +08:00
pomelo
48b77541c3 feat(i18n): Add Internationalization Support for UI and LLM Output (#1058) 2025-11-21 15:44:37 +08:00
tanzhenxin
f2439f8d53 fix: skip one unstable test case 2025-11-21 15:43:05 +08:00
tanzhenxin
fb6d0b43fa feat: change shortcut for subagent execution display 2025-11-21 15:42:17 +08:00
tanzhenxin
627283d357 feat: enhance usage statistics in qwen logger 2025-11-21 15:17:34 +08:00
tanzhenxin
640f30655d chore: pump version to 0.3.0 (#1085) 2025-11-21 09:37:38 +08:00
Kdump
9e5387f159 Headless enhancement: add stream-json as input-format/output-format to support programmatically use (#926) 2025-11-21 09:26:05 +08:00
tanzhenxin
442a9aed58 Replace spawn with execFile for memory-safe command execution (#1068) 2025-11-20 15:04:00 +08:00
Mingholy
a15b84e2a1 refactor(auth): enhance useAuthCommand to include history management and improve error handling in QwenOAuth2Client (#1077) 2025-11-20 14:37:39 +08:00
tanzhenxin
07069f00d1 feat: remove prompt completion feature (#1076) 2025-11-20 14:36:51 +08:00
pomelo
e1e7a0d606 Merge pull request #1074 from cwtuan/patch-1
fix: remove broken link
2025-11-20 14:33:14 +08:00
cwtuan
fc638851e7 fix: remove broken link 2025-11-20 12:50:06 +08:00
citlalinda
e1f793b2e0 fix: character encoding corruption when executing the /copy command on Windows. (#1069)
Co-authored-by: linda <hxn@163.com>
2025-11-20 10:23:17 +08:00
tanzhenxin
3c64f7bff5 chore: pump version to 0.2.3 (#1073) 2025-11-20 10:09:12 +08:00
tanzhenxin
97bf48b14c fix: skip problematic integration test (#1065) 2025-11-19 11:55:19 +08:00
Mingholy
d0e76c76a8 refactor(auth): save authType after successfully authenticated (#1036) 2025-11-19 11:21:46 +08:00
tanzhenxin
3ed93d5b5d fix: integration tests (#1062) 2025-11-19 10:23:16 +08:00
tanzhenxin
71646490f1 Fix: Improve ripgrep binary detection and cross-platform compatibility (#1060) 2025-11-18 19:38:30 +08:00
DS-Controller2
f0bbeac04a fix(core): add modelscope provider to handle stream_options (#848)
* fix(core): add modelscope provider to handle stream_options

---------

Co-authored-by: Qwen Code <qwen-code@alibaba-inc.com>
Co-authored-by: mingholy.lmh <mingholy.lmh@alibaba-inc.com>
2025-11-18 13:47:20 +08:00
Mingholy
efca0bc795 fix: basic slash command support (#1020) 2025-11-18 13:46:42 +08:00
tanzhenxin
6bb829f876 feat: Add Terminal Attention Notifications for User Alerts (#1052) 2025-11-18 13:43:43 +08:00
tanzhenxin
5bc309b3dc feat: add os platform and version in log report (#1053) 2025-11-18 13:43:17 +08:00
yyyanghj
0eeffc6875 feat: add support for Trae editor (#1037) 2025-11-17 10:58:33 +08:00
hj C
f0e21374c1 feat: add support for alternative cached_tokens format in OpenAI converter (#1035)
Co-authored-by: chenhuanjie <chenhuanjie@xiaohongshu.com>
2025-11-14 18:09:33 +08:00
BlockHand
29261c75e1 feat: openApi configurable window (#1019) 2025-11-14 10:18:57 +08:00
tanzhenxin
b4eba6584a chore: pump version to 0.2.2 (#1027) 2025-11-13 20:39:14 +08:00
XinlongWu
e6d08f0596 Change deepseek token limits regex patterns for deepseek-chat (#817) 2025-11-13 19:12:10 +08:00
tanzhenxin
160b64523e Add Interactive Approval Mode Dialog (#1012) 2025-11-13 19:02:53 +08:00
tanzhenxin
0752a31e1e 🎯 PR: Improve Edit Tool Reliability with Fuzzy Matching Pipeline (#1025) 2025-11-13 19:01:09 +08:00
fffly.Zzz
b029f0d2ce docs: correct YAML list format for tools field in agent template (#1026)
Co-authored-by: zhangxiao <xiao.zhang@ucloud.cn>
2025-11-13 17:41:22 +08:00
tanzhenxin
d5d96c726a fix: print request errors for logging only in debug mode (#1006) 2025-11-12 19:46:28 +08:00
tanzhenxin
06141cda8d Refactor: Standardize Tool Naming and Configuration System (#1004) 2025-11-12 19:46:05 +08:00
tanzhenxin
22edef0cb9 chore: pump version to 0.2.1 (#1005) 2025-11-10 15:18:59 +08:00
pomelo
ca1ae19715 Merge pull request #996 from wrapss/windows-newline-fix
fix: Stream parsing for Windows Zed integration
2025-11-10 09:52:47 +08:00
Matthieu Beaumont
6aaac12d70 fix(acp): replace EOL with newline for content splitting
- Replace `EOL` from `node:os` with `\n` for consistent line splitting in ACP connection output processing
- This ensures cross-platform compatibility since `EOL` is platform-specific while `\n` is universally used in text decoding
- The change maintains the same behavior on all platforms by using standard newline characters
2025-11-08 14:54:43 +01:00
Mingholy
3c01c7153b feat: enhance zed integration with TodoWriteTool and TaskTool support (#992)
- Implemented detection and handling for TodoWriteTool to route updates as plan entries instead of tool call events.
- Added sub-agent tool tracking for TaskTool, allowing for event emission and cleanup.
- Updated event listeners to manage sub-agent tool calls and approval requests effectively.
2025-11-07 19:55:23 +08:00
tanzhenxin
7a472e4fcf chore: pump version to 0.2.0 (#991) 2025-11-07 17:34:38 +08:00
pomelo
5390f662fc fix: VSCode detection null check and debug message optimization (#983) 2025-11-07 17:28:37 +08:00
tanzhenxin
c3d427730e 🎯 Feature: Customizable Model Training and Tool Output Management (#981) 2025-11-07 17:28:16 +08:00
pomelo
21fba6eb89 Merge pull request #977 from QwenLM/refactor-about
refactor: Unifying the system information display between `/about` and `/bug` commands
2025-11-07 11:02:20 +08:00
tanzhenxin
d17c37af7d Feat: Simplify and Improve Search Tools (glob, grep, ripgrep) (#969) 2025-11-06 16:25:06 +08:00
pomelo-nwu
82170e96c6 refactor(cli): centralize system information collection 2025-11-06 10:42:52 +08:00
pomelo
decb04efc4 Merge pull request #974 from QwenLM/chore/v0.1.5
chore: pump version to 0.1.5
2025-11-05 21:40:15 +08:00
pomelo-nwu
3bd0cb36c4 chore: pump version to 0.1.5 2025-11-05 19:35:17 +08:00
pomelo
553a36302a Merge pull request #972 from QwenLM/custom-logging-dir
feat: support for custom OpenAI logging directory configuration
2025-11-05 19:22:46 +08:00
pomelo
498d7a083a Merge pull request #970 from seems20/fix-kimi2-token-limits
Fix kimi2 token limits
2025-11-05 19:22:30 +08:00
pomelo-nwu
3a69931791 feat: add docs for logging dir configuration 2025-11-05 18:58:53 +08:00
pomelo-nwu
d4ab328671 feat: support for custom OpenAI logging directory configuration 2025-11-05 18:49:04 +08:00
chenhuanjie
90500ea67b Merge branch 'main' into fix-kimi2-token-limits 2025-11-05 17:36:02 +08:00
pomelo
335e765df0 Merge pull request #936 from QwenLM/fix-AbortError
fix: handle AbortError gracefully when loading commands
2025-11-05 16:38:14 +08:00
pomelo-nwu
448e30bf88 feat: support custom working directory for child process in start.js 2025-11-05 16:06:35 +08:00
chenhuanjie
26215b6d0a Merge branch 'main' into fix-kimi2-token-limits 2025-11-05 15:44:39 +08:00
chenhuanjie
f6f76a17e6 fix 2025-11-05 15:12:20 +08:00
chenhuanjie
55a3b69a8e fix 2025-11-05 15:10:52 +08:00
pomelo
22bd108775 Merge pull request #885 from QwenLM/web-search
chore: Web Search Tool Refactoring with Multi-Provider Support
2025-11-05 14:51:40 +08:00
pomelo-nwu
7ff07fd88c fix(web-search): handle unconfigured state and improve tests 2025-11-05 11:37:56 +08:00
pomelo-nwu
2967bec11c feat: update code 2025-11-05 11:23:27 +08:00
pomelo-nwu
6357a5c87e feat(web-search): enable DashScope provider only for Qwen OAuth auth type 2025-11-04 19:59:19 +08:00
tanzhenxin
7e827833bf chore: pump version to 0.1.4 (#962) 2025-11-04 19:22:37 +08:00
pomelo-nwu
d1507e73fe feat(web-search): use resource_url from credentials for DashScope endpoint 2025-11-04 16:59:30 +08:00
pomelo-nwu
512c91a969 Merge branch 'main' into web-search 2025-11-03 17:34:03 +08:00
pomelo-nwu
50d5cc2f6a fix: handle AbortError gracefully when loading commands 2025-10-31 17:00:28 +08:00
pomelo-nwu
40d82a2b25 feat: add docs for web search tool 2025-10-31 10:19:44 +08:00
pomelo-nwu
a40479d40a feat: adjust the description of the web search tool 2025-10-30 20:21:30 +08:00
pomelo-nwu
7cb068ceb2 Merge branch 'main' into web-search 2025-10-30 19:42:00 +08:00
pomelo-nwu
864bf03fee docs: add DashScope quota limits to web search documentation
- Add quota information (200 requests/minute, 2000 requests/day) to DashScope provider description
- Update provider details section with quota limits
2025-10-30 19:06:46 +08:00
pomelo-nwu
9a41db612a Add unit tests for web search core logic 2025-10-30 16:18:41 +08:00
pomelo-nwu
4781736f99 Improve web search fallback with snippet and web_fetch hint 2025-10-30 16:15:42 +08:00
pomelo-nwu
799d2bf0db feat: add oauth credit token 2025-10-27 19:59:13 +08:00
pomelo-nwu
741eaf91c2 feat: add web_search docs 2025-10-27 17:05:47 +08:00
pomelo-nwu
79b4821499 feat: Optimize the code 2025-10-27 11:24:38 +08:00
pomelo-nwu
b1ece177b7 feat: Optimize the code 2025-10-27 11:01:48 +08:00
pomelo-nwu
f9f6eb52dd feat: add multi websearch provider 2025-10-24 17:16:14 +08:00
294 changed files with 28776 additions and 6903 deletions

View File

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

11
.vscode/launch.json vendored
View File

@@ -73,7 +73,16 @@
"request": "launch",
"name": "Launch CLI Non-Interactive",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"],
"runtimeArgs": [
"run",
"start",
"--",
"-p",
"${input:prompt}",
"-y",
"--output-format",
"stream-json"
],
"skipFiles": ["<node_internals>/**"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",

View File

@@ -25,7 +25,7 @@
</div>
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 💡 Free Options Available

View File

@@ -195,6 +195,16 @@ Slash commands provide meta-level control over the CLI itself.
- **`/init`**
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
- [**`/language`**](./language.md)
- **Description:** View or change the language setting for both UI and LLM output.
- **Sub-commands:**
- **`ui`**: Set the UI language (zh-CN or en-US)
- **`output`**: Set the LLM output language
- **Usage:** `/language [ui|output] [language]`
- **Examples:**
- `/language ui zh-CN` (set UI language to Simplified Chinese)
- `/language output English` (set LLM output language to English)
### Custom Commands
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.

View File

@@ -309,7 +309,8 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
```
- **`tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality.
- **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
- **Default:** `undefined` (web search disabled)
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
- **`chatCompression`** (object):
@@ -465,8 +466,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- This is useful for development and testing.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Used to enable the `web_search` tool functionality.
- **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -540,6 +541,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--openai-logging-dir <directory>`**:
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here`

View File

@@ -160,9 +160,30 @@ Settings are organized into categories. All settings should be placed within the
- **Default:** `undefined`
- **`model.chatCompression.contextPercentageThreshold`** (number):
- **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit.
- **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely.
- **Default:** `0.7`
- **`model.generationConfig`** (object):
- **Description:** Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults.
- **Default:** `undefined`
- **Example:**
```json
{
"model": {
"generationConfig": {
"timeout": 60000,
"disableCacheControl": false,
"samplingParams": {
"temperature": 0.2,
"top_p": 0.8,
"max_tokens": 1024
}
}
}
}
```
- **`model.skipNextSpeakerCheck`** (boolean):
- **Description:** Skip the next speaker check.
- **Default:** `false`
@@ -171,6 +192,22 @@ Settings are organized into categories. All settings should be placed within the
- **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
- **Default:** `false`
- **`model.skipStartupContext`** (boolean):
- **Description:** Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup.
- **Default:** `false`
- **`model.enableOpenAILogging`** (boolean):
- **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files.
- **Default:** `false`
- **`model.openAILoggingDir`** (string):
- **Description:** Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory).
- **Default:** `undefined`
- **Examples:**
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
#### `context`
- **`context.fileName`** (string or array of strings):
@@ -254,6 +291,21 @@ Settings are organized into categories. All settings should be placed within the
- **Description:** Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`.
- **Default:** `true`
- **`tools.enableToolOutputTruncation`** (boolean):
- **Description:** Enable truncation of large tool outputs.
- **Default:** `true`
- **Requires restart:** Yes
- **`tools.truncateToolOutputThreshold`** (number):
- **Description:** Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
- **Default:** `25000`
- **Requires restart:** Yes
- **`tools.truncateToolOutputLines`** (number):
- **Description:** Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
- **Default:** `1000`
- **Requires restart:** Yes
#### `mcp`
- **`mcp.serverCommand`** (string):
@@ -305,7 +357,8 @@ Settings are organized into categories. All settings should be placed within the
- **Default:** `undefined`
- **`advanced.tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Description:** API key for Tavily web search service. Used to enable the `web_search` tool functionality.
- **Note:** This is a legacy configuration format. For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers using the new `webSearch` configuration format.
- **Default:** `undefined`
#### `mcpServers`
@@ -386,6 +439,8 @@ Here is an example of a `settings.json` file with the nested structure, new as o
"model": {
"name": "qwen3-coder-plus",
"maxSessionTurns": 10,
"enableOpenAILogging": false,
"openAILoggingDir": "~/qwen-logs",
"summarizeToolOutput": {
"run_shell_command": {
"tokenBudget": 100
@@ -474,8 +529,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- Set to a string to customize the title of the CLI.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Used to enable the `web_search` tool functionality.
- **Note:** For Qwen OAuth users, DashScope provider is automatically available without any configuration. For other authentication types, configure Tavily or Google providers to enable web search.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -493,12 +548,25 @@ Arguments passed directly when running the CLI can override other configurations
- The prompt is processed within the interactive session, not before it.
- Cannot be used when piping input from stdin.
- Example: `qwen -i "explain this code"`
- **`--output-format <format>`**:
- **`--output-format <format>`** (**`-o <format>`**):
- **Description:** Specifies the format of the CLI output for non-interactive mode.
- **Values:**
- `text`: (Default) The standard human-readable output.
- `json`: A machine-readable JSON output.
- **Note:** For structured output and scripting, use the `--output-format json` flag.
- `json`: A machine-readable JSON output emitted at the end of execution.
- `stream-json`: Streaming JSON messages emitted as they occur during execution.
- **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless.md) for detailed information.
- **`--input-format <format>`**:
- **Description:** Specifies the format consumed from standard input.
- **Values:**
- `text`: (Default) Standard text input from stdin or command-line arguments.
- `stream-json`: JSON message protocol via stdin for bidirectional communication.
- **Requirement:** `--input-format stream-json` requires `--output-format stream-json` to be set.
- **Note:** When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless.md) for detailed information.
- **`--include-partial-messages`**:
- **Description:** Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming.
- **Default:** `false`
- **Requirement:** Requires `--output-format stream-json` to be set.
- **Note:** See [Headless Mode](../features/headless.md) for detailed information about stream events.
- **`--sandbox`** (**`-s`**):
- Enables sandbox mode for this session.
- **`--sandbox-image`**:
@@ -523,7 +591,7 @@ Arguments passed directly when running the CLI can override other configurations
- Example: `qwen --approval-mode auto-edit`
- **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation dialog.
- Example: `qwen --allowed-tools "ShellTool(git status)"`
- Example: `qwen --allowed-tools "Shell(git status)"`
- **`--telemetry`**:
- Enables [telemetry](../telemetry.md).
- **`--telemetry-target`**:
@@ -556,6 +624,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--openai-logging-dir <directory>`**:
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `qwen --tavily-api-key tvly-your-api-key-here`

71
docs/cli/language.md Normal file
View File

@@ -0,0 +1,71 @@
# Language Command
The `/language` command allows you to customize the language settings for both the Qwen Code user interface (UI) and the language model's output. This command supports two distinct functionalities:
1. Setting the UI language for the Qwen Code interface
2. Setting the output language for the language model (LLM)
## UI Language Settings
To change the UI language of Qwen Code, use the `ui` subcommand:
```
/language ui [zh-CN|en-US]
```
### Available UI Languages
- **zh-CN**: Simplified Chinese (简体中文)
- **en-US**: English
### Examples
```
/language ui zh-CN # Set UI language to Simplified Chinese
/language ui en-US # Set UI language to English
```
### UI Language Subcommands
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`
## LLM Output Language Settings
To set the language for the language model's responses, use the `output` subcommand:
```
/language output <language>
```
This command generates a language rule file that instructs the LLM to respond in the specified language. The rule file is saved to `~/.qwen/output-language.md`.
### Examples
```
/language output 中文 # Set LLM output language to Chinese
/language output English # Set LLM output language to English
/language output 日本語 # Set LLM output language to Japanese
```
## Viewing Current Settings
When used without arguments, the `/language` command displays the current language settings:
```
/language
```
This will show:
- Current UI language
- Current LLM output language (if set)
- Available subcommands
## Notes
- UI language changes take effect immediately and reload all command descriptions
- LLM output language settings are persisted in a rule file that is automatically included in the model's context
- To request additional UI language packs, please open an issue on GitHub

View File

@@ -21,7 +21,7 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi
- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content.
- **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for:
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`).
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ListFiles`, `ReadFile`).
- **Discovering Tools:** It can also discover tools dynamically:
- **Command-based Discovery:** If `tools.toolDiscoveryCommand` is configured in settings, this command is executed. It's expected to output JSON describing custom tools, which are then registered as `DiscoveredTool` instances.
- **MCP-based Discovery:** If `mcp.mcpServerCommand` is configured, the registry can connect to a Model Context Protocol (MCP) server to list and register tools (`DiscoveredMCPTool`).
@@ -33,20 +33,24 @@ The Qwen Code core (`packages/core`) features a robust system for defining, regi
The core comes with a suite of pre-defined tools, typically found in `packages/core/src/tools/`. These include:
- **File System Tools:**
- `LSTool` (`ls.ts`): Lists directory contents.
- `ReadFileTool` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path.
- `WriteFileTool` (`write-file.ts`): Writes content to a file.
- `GrepTool` (`grep.ts`): Searches for patterns in files.
- `GlobTool` (`glob.ts`): Finds files matching glob patterns.
- `EditTool` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation).
- `ReadManyFilesTool` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI).
- `ListFiles` (`ls.ts`): Lists directory contents.
- `ReadFile` (`read-file.ts`): Reads the content of a single file. It takes an `absolute_path` parameter, which must be an absolute path.
- `WriteFile` (`write-file.ts`): Writes content to a file.
- `ReadManyFiles` (`read-many-files.ts`): Reads and concatenates content from multiple files or glob patterns (used by the `@` command in CLI).
- `Grep` (`grep.ts`): Searches for patterns in files.
- `Glob` (`glob.ts`): Finds files matching glob patterns.
- `Edit` (`edit.ts`): Performs in-place modifications to files (often requiring confirmation).
- **Execution Tools:**
- `ShellTool` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation).
- `Shell` (`shell.ts`): Executes arbitrary shell commands (requires careful sandboxing and user confirmation).
- **Web Tools:**
- `WebFetchTool` (`web-fetch.ts`): Fetches content from a URL.
- `WebSearchTool` (`web-search.ts`): Performs a web search.
- `WebFetch` (`web-fetch.ts`): Fetches content from a URL.
- `WebSearch` (`web-search.ts`): Performs a web search.
- **Memory Tools:**
- `MemoryTool` (`memoryTool.ts`): Interacts with the AI's memory.
- `SaveMemory` (`memoryTool.ts`): Interacts with the AI's memory.
- **Planning Tools:**
- `Task` (`task.ts`): Delegates tasks to specialized subagents.
- `TodoWrite` (`todoWrite.ts`): Creates and manages a structured task list.
- `ExitPlanMode` (`exitPlanMode.ts`): Exits plan mode and returns to normal operation.
Each of these tools extends `BaseTool` and implements the required methods for its specific functionality.

View File

@@ -13,8 +13,9 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
- [Output Formats](#output-formats)
- [Text Output (Default)](#text-output-default)
- [JSON Output](#json-output)
- [Response Schema](#response-schema)
- [Example Usage](#example-usage)
- [Stream-JSON Output](#stream-json-output)
- [Input Format](#input-format)
- [File Redirection](#file-redirection)
- [Configuration Options](#configuration-options)
- [Examples](#examples)
@@ -22,7 +23,7 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
- [Generate commit messages](#generate-commit-messages)
- [API documentation](#api-documentation)
- [Batch code analysis](#batch-code-analysis)
- [Code review](#code-review-1)
- [PR code review](#pr-code-review)
- [Log analysis](#log-analysis)
- [Release notes generation](#release-notes-generation)
- [Model and tool usage tracking](#model-and-tool-usage-tracking)
@@ -66,6 +67,8 @@ cat README.md | qwen --prompt "Summarize this documentation"
## Output Formats
Qwen Code supports multiple output formats for different use cases:
### Text Output (Default)
Standard human-readable output:
@@ -82,56 +85,9 @@ The capital of France is Paris.
### JSON Output
Returns structured data including response, statistics, and metadata. This
format is ideal for programmatic processing and automation scripts.
Returns structured data as a JSON array. All messages are buffered and output together when the session completes. This format is ideal for programmatic processing and automation scripts.
#### Response Schema
The JSON output follows this high-level structure:
```json
{
"response": "string", // The main AI-generated content answering your prompt
"stats": {
// Usage metrics and performance data
"models": {
// Per-model API and token usage statistics
"[model-name]": {
"api": {
/* request counts, errors, latency */
},
"tokens": {
/* prompt, response, cached, total counts */
}
}
},
"tools": {
// Tool execution statistics
"totalCalls": "number",
"totalSuccess": "number",
"totalFail": "number",
"totalDurationMs": "number",
"totalDecisions": {
/* accept, reject, modify, auto_accept counts */
},
"byName": {
/* per-tool detailed stats */
}
},
"files": {
// File modification statistics
"totalLinesAdded": "number",
"totalLinesRemoved": "number"
}
},
"error": {
// Present only when an error occurred
"type": "string", // Error type (e.g., "ApiError", "AuthError")
"message": "string", // Human-readable error description
"code": "number" // Optional error code
}
}
```
The JSON output is an array of message objects. The output includes multiple message types: system messages (session initialization), assistant messages (AI responses), and result messages (execution summary).
#### Example Usage
@@ -139,63 +95,81 @@ The JSON output follows this high-level structure:
qwen -p "What is the capital of France?" --output-format json
```
Response:
Output (at end of execution):
```json
{
"response": "The capital of France is Paris.",
"stats": {
"models": {
"qwen3-coder-plus": {
"api": {
"totalRequests": 2,
"totalErrors": 0,
"totalLatencyMs": 5053
[
{
"type": "system",
"subtype": "session_start",
"uuid": "...",
"session_id": "...",
"model": "qwen3-coder-plus",
...
},
"tokens": {
"prompt": 24939,
"candidates": 20,
"total": 25113,
"cached": 21263,
"thoughts": 154,
"tool": 0
}
{
"type": "assistant",
"uuid": "...",
"session_id": "...",
"message": {
"id": "...",
"type": "message",
"role": "assistant",
"model": "qwen3-coder-plus",
"content": [
{
"type": "text",
"text": "The capital of France is Paris."
}
],
"usage": {...}
},
"tools": {
"totalCalls": 1,
"totalSuccess": 1,
"totalFail": 0,
"totalDurationMs": 1881,
"totalDecisions": {
"accept": 0,
"reject": 0,
"modify": 0,
"auto_accept": 1
"parent_tool_use_id": null
},
"byName": {
"google_web_search": {
"count": 1,
"success": 1,
"fail": 0,
"durationMs": 1881,
"decisions": {
"accept": 0,
"reject": 0,
"modify": 0,
"auto_accept": 1
{
"type": "result",
"subtype": "success",
"uuid": "...",
"session_id": "...",
"is_error": false,
"duration_ms": 1234,
"result": "The capital of France is Paris.",
"usage": {...}
}
}
}
},
"files": {
"totalLinesAdded": 0,
"totalLinesRemoved": 0
}
}
}
]
```
### Stream-JSON Output
Stream-JSON format emits JSON messages immediately as they occur during execution, enabling real-time monitoring. This format uses line-delimited JSON where each message is a complete JSON object on a single line.
```bash
qwen -p "Explain TypeScript" --output-format stream-json
```
Output (streaming as events occur):
```json
{"type":"system","subtype":"session_start","uuid":"...","session_id":"..."}
{"type":"assistant","uuid":"...","session_id":"...","message":{...}}
{"type":"result","subtype":"success","uuid":"...","session_id":"..."}
```
When combined with `--include-partial-messages`, additional stream events are emitted in real-time (message_start, content_block_delta, etc.) for real-time UI updates.
```bash
qwen -p "Write a Python script" --output-format stream-json --include-partial-messages
```
### Input Format
The `--input-format` parameter controls how Qwen Code consumes input from standard input:
- **`text`** (default): Standard text input from stdin or command-line arguments
- **`stream-json`**: JSON message protocol via stdin for bidirectional communication
> **Note:** Stream-json input mode is currently under construction and is intended for SDK integration. It requires `--output-format stream-json` to be set.
### File Redirection
Save output to files or pipe to other commands:
@@ -212,6 +186,10 @@ qwen -p "Add more details" >> docker-explanation.txt
qwen -p "What is Kubernetes?" --output-format json | jq '.response'
qwen -p "Explain microservices" | wc -w
qwen -p "List programming languages" | grep -i "python"
# Stream-JSON output for real-time processing
qwen -p "Explain Docker" --output-format stream-json | jq '.type'
qwen -p "Write code" --output-format stream-json --include-partial-messages | jq '.event.type'
```
## Configuration Options
@@ -219,10 +197,11 @@ qwen -p "List programming languages" | grep -i "python"
Key command-line options for headless usage:
| Option | Description | Example |
| ----------------------- | ---------------------------------- | ------------------------------------------------ |
| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ |
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
| `--output-format` | Specify output format (text, json) | `qwen -p "query" --output-format json` |
| `--model`, `-m` | Specify the Qwen model | `qwen -p "query" -m qwen3-coder-plus` |
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
@@ -233,27 +212,27 @@ For complete details on all available configuration options, settings files, and
## Examples
#### Code review
### Code review
```bash
cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt
```
#### Generate commit messages
### Generate commit messages
```bash
result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json)
echo "$result" | jq -r '.response'
```
#### API documentation
### API documentation
```bash
result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json)
echo "$result" | jq -r '.response' > openapi.json
```
#### Batch code analysis
### Batch code analysis
```bash
for file in src/*.py; do
@@ -264,20 +243,20 @@ for file in src/*.py; do
done
```
#### Code review
### PR code review
```bash
result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json)
echo "$result" | jq -r '.response' > pr-review.json
```
#### Log analysis
### Log analysis
```bash
grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt
```
#### Release notes generation
### Release notes generation
```bash
result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json)
@@ -286,7 +265,7 @@ echo "$response"
echo "$response" >> CHANGELOG.md
```
#### Model and tool usage tracking
### Model and tool usage tracking
```bash
result=$(qwen -p "Explain this database schema" --include-directories db --output-format json)

View File

@@ -106,7 +106,10 @@ Subagents are configured using Markdown files with YAML frontmatter. This format
---
name: agent-name
description: Brief description of when and how to use this agent
tools: tool1, tool2, tool3 # Optional
tools:
- tool1
- tool2
- tool3 # Optional
---
System prompt content goes here.
@@ -167,7 +170,11 @@ Perfect for comprehensive test creation and test-driven development.
---
name: testing-expert
description: Writes comprehensive unit tests, integration tests, and handles test automation with best practices
tools: read_file, write_file, read_many_files, run_shell_command
tools:
- read_file
- write_file
- read_many_files
- run_shell_command
---
You are a testing specialist focused on creating high-quality, maintainable tests.
@@ -207,7 +214,11 @@ Specialized in creating clear, comprehensive documentation.
---
name: documentation-writer
description: Creates comprehensive documentation, README files, API docs, and user guides
tools: read_file, write_file, read_many_files, web_search
tools:
- read_file
- write_file
- read_many_files
- web_search
---
You are a technical documentation specialist for ${project_name}.
@@ -256,7 +267,9 @@ Focused on code quality, security, and best practices.
---
name: code-reviewer
description: Reviews code for best practices, security issues, performance, and maintainability
tools: read_file, read_many_files
tools:
- read_file
- read_many_files
---
You are an experienced code reviewer focused on quality, security, and maintainability.
@@ -298,7 +311,11 @@ Optimized for React development, hooks, and component patterns.
---
name: react-specialist
description: Expert in React development, hooks, component patterns, and modern React best practices
tools: read_file, write_file, read_many_files, run_shell_command
tools:
- read_file
- write_file
- read_many_files
- run_shell_command
---
You are a React specialist with deep expertise in modern React development.
@@ -339,7 +356,11 @@ Specialized in Python development, frameworks, and best practices.
---
name: python-expert
description: Expert in Python development, frameworks, testing, and Python-specific best practices
tools: read_file, write_file, read_many_files, run_shell_command
tools:
- read_file
- write_file
- read_many_files
- run_shell_command
---
You are a Python expert with deep knowledge of the Python ecosystem.

View File

@@ -14,6 +14,13 @@ This guide provides solutions to common issues and debugging tips, including top
- **Solution:** Set the `NODE_EXTRA_CA_CERTS` environment variable to the absolute path of your corporate root CA certificate file.
- Example: `export NODE_EXTRA_CA_CERTS=/path/to/your/corporate-ca.crt`
- **Issue: Unable to display UI after authentication failure**
- **Cause:** If authentication fails after selecting an authentication type, the `security.auth.selectedType` setting may be persisted in `settings.json`. On restart, the CLI may get stuck trying to authenticate with the failed auth type and fail to display the UI.
- **Solution:** Clear the `security.auth.selectedType` configuration item in your `settings.json` file:
- Open `~/.qwen/settings.json` (or `./.qwen/settings.json` for project-specific settings)
- Remove the `security.auth.selectedType` field
- Restart the CLI to allow it to prompt for authentication again
## Frequently asked questions (FAQs)
- **Q: How do I update Qwen Code to the latest version?**

View File

@@ -4,12 +4,12 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
**Note:** All file system tools operate within a `rootDirectory` (usually the current working directory where you launched the CLI) for security. Paths that you provide to these tools are generally expected to be absolute or are resolved relative to this root directory.
## 1. `list_directory` (ReadFolder)
## 1. `list_directory` (ListFiles)
`list_directory` lists the names of files and subdirectories directly within a specified directory path. It can optionally ignore entries matching provided glob patterns.
- **Tool name:** `list_directory`
- **Display name:** ReadFolder
- **Display name:** ListFiles
- **File:** `ls.ts`
- **Parameters:**
- `path` (string, required): The absolute path to the directory to list.
@@ -59,86 +59,80 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
- **Output (`llmContent`):** A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt` or `Successfully created and wrote to new file: /path/to/new/file.txt`.
- **Confirmation:** Yes. Shows a diff of changes and asks for user approval before writing.
## 4. `glob` (FindFiles)
## 4. `glob` (Glob)
`glob` finds files matching specific glob patterns (e.g., `src/**/*.ts`, `*.md`), returning absolute paths sorted by modification time (newest first).
- **Tool name:** `glob`
- **Display name:** FindFiles
- **Display name:** Glob
- **File:** `glob.ts`
- **Parameters:**
- `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`).
- `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory.
- `case_sensitive` (boolean, optional): Whether the search should be case-sensitive. Defaults to `false`.
- `respect_git_ignore` (boolean, optional): Whether to respect .gitignore patterns when finding files. Defaults to `true`.
- `path` (string, optional): The directory to search in. If not specified, the current working directory will be used.
- **Behavior:**
- Searches for files matching the glob pattern within the specified directory.
- Returns a list of absolute paths, sorted with the most recently modified files first.
- Ignores common nuisance directories like `node_modules` and `.git` by default.
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
- Respects .gitignore and .qwenignore patterns by default.
- Limits results to 100 files to prevent context overflow.
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within /path/to/search/dir, sorted by modification time (newest first):\n---\n/path/to/file1.ts\n/path/to/subdir/file2.ts\n---\n[95 files truncated] ...`
- **Confirmation:** No.
## 5. `search_file_content` (SearchText)
## 5. `grep_search` (Grep)
`search_file_content` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
`grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
- **Tool name:** `search_file_content`
- **Display name:** SearchText
- **File:** `grep.ts`
- **Tool name:** `grep_search`
- **Display name:** Grep
- **File:** `ripGrep.ts` (with `grep.ts` as fallback)
- **Parameters:**
- `pattern` (string, required): The regular expression (regex) to search for (e.g., `"function\s+myFunction"`).
- `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory.
- `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores).
- `maxResults` (number, optional): Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.
- `pattern` (string, required): The regular expression pattern to search for in file contents (e.g., `"function\\s+myFunction"`, `"log.*Error"`).
- `path` (string, optional): File or directory to search in. Defaults to current working directory.
- `glob` (string, optional): Glob pattern to filter files (e.g. `"*.js"`, `"src/**/*.{ts,tsx}"`).
- `limit` (number, optional): Limit output to first N matching lines. Optional - shows all matches if not specified.
- **Behavior:**
- Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search.
- Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number.
- Limits results to a maximum of 20 matches by default to prevent context overflow. When results are truncated, shows a clear warning with guidance on refining searches.
- Uses ripgrep for fast search when available; otherwise falls back to a JavaScript-based search implementation.
- Returns matching lines with file paths and line numbers.
- Case-insensitive by default.
- Respects .gitignore and .qwenignore patterns.
- Limits output to prevent context overflow.
- **Output (`llmContent`):** A formatted string of matches, e.g.:
```
Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"):
---
File: src/utils.ts
L15: export function myFunction() {
L22: myFunction.call();
---
File: src/index.ts
L5: import { myFunction } from './utils';
src/utils.ts:15:export function myFunction() {
src/utils.ts:22: myFunction.call();
src/index.ts:5:import { myFunction } from './utils';
---
WARNING: Results truncated to prevent context overflow. To see more results:
- Use a more specific pattern to reduce matches
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
- Specify a narrower 'path' to search in a subdirectory
- Increase 'maxResults' parameter if you need more matches (current: 20)
[0 lines truncated] ...
```
- **Confirmation:** No.
### `search_file_content` examples
### `grep_search` examples
Search for a pattern with default result limiting:
```
search_file_content(pattern="function\s+myFunction", path="src")
grep_search(pattern="function\\s+myFunction", path="src")
```
Search for a pattern with custom result limiting:
```
search_file_content(pattern="function", path="src", maxResults=50)
grep_search(pattern="function", path="src", limit=50)
```
Search for a pattern with file filtering and custom result limiting:
```
search_file_content(pattern="function", include="*.js", maxResults=10)
grep_search(pattern="function", glob="*.js", limit=10)
```
## 6. `edit` (Edit)
`edit` replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when `expected_replacements` is specified. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location.
`edit` replaces text within a file. By default it requires `old_string` to match a single unique location; set `replace_all` to `true` when you intentionally want to change every occurrence. This tool is designed for precise, targeted changes and requires significant context around the `old_string` to ensure it modifies the correct location.
- **Tool name:** `edit`
- **Display name:** Edit
@@ -150,12 +144,12 @@ search_file_content(pattern="function", include="*.js", maxResults=10)
**CRITICAL:** This string must uniquely identify the single instance to change. It should include at least 3 lines of context _before_ and _after_ the target text, matching whitespace and indentation precisely. If `old_string` is empty, the tool attempts to create a new file at `file_path` with `new_string` as content.
- `new_string` (string, required): The exact literal text to replace `old_string` with.
- `expected_replacements` (number, optional): The number of occurrences to replace. Defaults to `1`.
- `replace_all` (boolean, optional): Replace all occurrences of `old_string`. Defaults to `false`.
- **Behavior:**
- If `old_string` is empty and `file_path` does not exist, creates a new file with `new_string` as content.
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence of `old_string`.
- If one occurrence is found, it replaces it with `new_string`.
- If `old_string` is provided, it reads the `file_path` and attempts to find exactly one occurrence unless `replace_all` is true.
- If the match is unique (or `replace_all` is true), it replaces the text with `new_string`.
- **Enhanced Reliability (Multi-Stage Edit Correction):** To significantly improve the success rate of edits, especially when the model-provided `old_string` might not be perfectly precise, the tool incorporates a multi-stage edit correction mechanism.
- If the initial `old_string` isn't found or matches multiple locations, the tool can leverage the Qwen model to iteratively refine `old_string` (and potentially `new_string`).
- This self-correction process attempts to identify the unique segment the model intended to modify, making the `edit` operation more robust even with slightly imperfect initial context.
@@ -164,10 +158,10 @@ search_file_content(pattern="function", include="*.js", maxResults=10)
- `old_string` is not empty, but the `file_path` does not exist.
- `old_string` is empty, but the `file_path` already exists.
- `old_string` is not found in the file after attempts to correct it.
- `old_string` is found multiple times, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
- `old_string` is found multiple times, `replace_all` is false, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
- **Output (`llmContent`):**
- On success: `Successfully modified file: /path/to/file.txt (1 replacements).` or `Created new file: /path/to/new_file.txt with provided content.`
- On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit, expected 1 occurrences but found 2...`).
- On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit because the text matches multiple locations...`).
- **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file.
These file system tools provide a foundation for Qwen Code to understand and interact with your local project context.

View File

@@ -1,43 +1,186 @@
# Web Search Tool (`web_search`)
This document describes the `web_search` tool.
This document describes the `web_search` tool for performing web searches using multiple providers.
## Description
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
Use `web_search` to perform a web search and get information from the internet. The tool supports multiple search providers and returns a concise answer with source citations when available.
### Supported Providers
1. **DashScope** (Official, Free) - Automatically available for Qwen OAuth users (200 requests/minute, 2000 requests/day)
2. **Tavily** - High-quality search API with built-in answer generation
3. **Google Custom Search** - Google's Custom Search JSON API
### Arguments
`web_search` takes one argument:
`web_search` takes two arguments:
- `query` (string, required): The search query.
- `query` (string, required): The search query
- `provider` (string, optional): Specific provider to use ("dashscope", "tavily", "google")
- If not specified, uses the default provider from configuration
## How to use `web_search`
## Configuration
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
### Method 1: Settings File (Recommended)
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
Add to your `settings.json`:
If the key is not configured, the tool will be disabled and skipped.
Usage:
```
web_search(query="Your query goes here.")
```json
{
"webSearch": {
"provider": [
{ "type": "dashscope" },
{ "type": "tavily", "apiKey": "tvly-xxxxx" },
{
"type": "google",
"apiKey": "your-google-api-key",
"searchEngineId": "your-search-engine-id"
}
],
"default": "dashscope"
}
}
```
## `web_search` examples
**Notes:**
Get information on a topic:
- DashScope doesn't require an API key (official, free service)
- **Qwen OAuth users:** DashScope is automatically added to your provider list, even if not explicitly configured
- Configure additional providers (Tavily, Google) if you want to use them alongside DashScope
- Set `default` to specify which provider to use by default (if not set, priority order: Tavily > Google > DashScope)
```
web_search(query="latest advancements in AI-powered code generation")
### Method 2: Environment Variables
Set environment variables in your shell or `.env` file:
```bash
# Tavily
export TAVILY_API_KEY="tvly-xxxxx"
# Google
export GOOGLE_API_KEY="your-api-key"
export GOOGLE_SEARCH_ENGINE_ID="your-engine-id"
```
## Important notes
### Method 3: Command Line Arguments
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
- **Citations:** Source links are appended as a numbered list.
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.
Pass API keys when running Qwen Code:
```bash
# Tavily
qwen --tavily-api-key tvly-xxxxx
# Google
qwen --google-api-key your-key --google-search-engine-id your-id
# Specify default provider
qwen --web-search-default tavily
```
### Backward Compatibility (Deprecated)
⚠️ **DEPRECATED:** The legacy `tavilyApiKey` configuration is still supported for backward compatibility but is deprecated:
```json
{
"advanced": {
"tavilyApiKey": "tvly-xxxxx" // ⚠️ Deprecated
}
}
```
**Important:** This configuration is deprecated and will be removed in a future version. Please migrate to the new `webSearch` configuration format shown above. The old configuration will automatically configure Tavily as a provider, but we strongly recommend updating your configuration.
## Disabling Web Search
If you want to disable the web search functionality, you can exclude the `web_search` tool in your `settings.json`:
```json
{
"tools": {
"exclude": ["web_search"]
}
}
```
**Note:** This setting requires a restart of Qwen Code to take effect. Once disabled, the `web_search` tool will not be available to the model, even if web search providers are configured.
## Usage Examples
### Basic search (using default provider)
```
web_search(query="latest advancements in AI")
```
### Search with specific provider
```
web_search(query="latest advancements in AI", provider="tavily")
```
### Real-world examples
```
web_search(query="weather in San Francisco today")
web_search(query="latest Node.js LTS version", provider="google")
web_search(query="best practices for React 19", provider="dashscope")
```
## Provider Details
### DashScope (Official)
- **Cost:** Free
- **Authentication:** Automatically available when using Qwen OAuth authentication
- **Configuration:** No API key required, automatically added to provider list for Qwen OAuth users
- **Quota:** 200 requests/minute, 2000 requests/day
- **Best for:** General queries, always available as fallback for Qwen OAuth users
- **Auto-registration:** If you're using Qwen OAuth, DashScope is automatically added to your provider list even if you don't configure it explicitly
### Tavily
- **Cost:** Requires API key (paid service with free tier)
- **Sign up:** https://tavily.com
- **Features:** High-quality results with AI-generated answers
- **Best for:** Research, comprehensive answers with citations
### Google Custom Search
- **Cost:** Free tier available (100 queries/day)
- **Setup:**
1. Enable Custom Search API in Google Cloud Console
2. Create a Custom Search Engine at https://programmablesearchengine.google.com
- **Features:** Google's search quality
- **Best for:** Specific, factual queries
## Important Notes
- **Response format:** Returns a concise answer with numbered source citations
- **Citations:** Source links are appended as a numbered list: [1], [2], etc.
- **Multiple providers:** If one provider fails, manually specify another using the `provider` parameter
- **DashScope availability:** Automatically available for Qwen OAuth users, no configuration needed
- **Default provider selection:** The system automatically selects a default provider based on availability:
1. Your explicit `default` configuration (highest priority)
2. CLI argument `--web-search-default`
3. First available provider by priority: Tavily > Google > DashScope
## Troubleshooting
**Tool not available?**
- **For Qwen OAuth users:** The tool is automatically registered with DashScope provider, no configuration needed
- **For other authentication types:** Ensure at least one provider (Tavily or Google) is configured
- For Tavily/Google: Verify your API keys are correct
**Provider-specific errors?**
- Use the `provider` parameter to try a different search provider
- Check your API quotas and rate limits
- Verify API keys are properly set in configuration
**Need help?**
- Check your configuration: Run `qwen` and use the settings dialog
- View your current settings in `~/.qwen-code/settings.json` (macOS/Linux) or `%USERPROFILE%\.qwen-code\settings.json` (Windows)

View File

@@ -21,23 +21,21 @@ describe('Interactive Mode', () => {
it.skipIf(process.platform === 'win32')(
'should trigger chat compression with /compress command',
async () => {
await rig.setup('interactive-compress-test');
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(
@@ -68,35 +66,30 @@ describe('Interactive Mode', () => {
},
);
it.skipIf(process.platform === 'win32')(
'should handle compression failure on token inflation',
async () => {
await rig.setup('interactive-compress-test');
it.skip('should handle compression failure on token inflation', async () => {
await rig.setup('interactive-compress-test', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
const { ptyProcess } = rig.runInteractive();
let fullOutput = '';
ptyProcess.onData((data) => (fullOutput += data));
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 25000);
expect(
isReady,
'CLI did not start up in interactive mode correctly',
).toBe(true);
expect(isReady, 'CLI did not start up in interactive mode correctly').toBe(
true,
);
await type(ptyProcess, '/compress');
await new Promise((resolve) => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 1000));
await type(ptyProcess, '\r');
const foundEvent = await rig.waitForTelemetryEvent(
@@ -106,11 +99,10 @@ describe('Interactive Mode', () => {
expect(foundEvent).toBe(true);
const compressionFailed = await rig.waitForText(
'compression was not beneficial',
'Nothing to compress.',
25000,
);
expect(compressionFailed).toBe(true);
},
);
});
});

View File

@@ -22,21 +22,19 @@ describe('Interactive file system', () => {
'should perform a read-then-write sequence in interactive mode',
async () => {
const fileName = 'version.txt';
await rig.setup('interactive-read-then-write');
await rig.setup('interactive-read-then-write', {
settings: {
security: {
auth: {
selectedType: 'openai',
},
},
},
});
rig.createFile(fileName, '1.0.0');
const { ptyProcess } = rig.runInteractive();
const authDialogAppeared = await rig.waitForText(
'How would you like to authenticate',
5000,
);
// select the second option if auth dialog come's up
if (authDialogAppeared) {
ptyProcess.write('2');
}
// Wait for the app to be ready
const isReady = await rig.waitForText('Type your message', 15000);
expect(

View File

@@ -19,7 +19,7 @@ describe('JSON output', () => {
await rig.cleanup();
});
it('should return a valid JSON with response and stats', async () => {
it('should return a valid JSON array with result message containing response and stats', async () => {
const result = await rig.run(
'What is the capital of France?',
'--output-format',
@@ -27,15 +27,34 @@ describe('JSON output', () => {
);
const parsed = JSON.parse(result);
expect(parsed).toHaveProperty('response');
expect(typeof parsed.response).toBe('string');
expect(parsed.response.toLowerCase()).toContain('paris');
// The output should be an array of messages
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBeGreaterThan(0);
expect(parsed).toHaveProperty('stats');
expect(typeof parsed.stats).toBe('object');
// Find the result message (should be the last message)
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage).toBeDefined();
expect(resultMessage).toHaveProperty('is_error');
expect(resultMessage.is_error).toBe(false);
expect(resultMessage).toHaveProperty('result');
expect(typeof resultMessage.result).toBe('string');
expect(resultMessage.result.toLowerCase()).toContain('paris');
// Stats may be present if available
if ('stats' in resultMessage) {
expect(typeof resultMessage.stats).toBe('object');
}
});
it('should return a JSON error for enforced auth mismatch before running', async () => {
const originalOpenaiApiKey = process.env['OPENAI_API_KEY'];
process.env['OPENAI_API_KEY'] = 'test-key';
await rig.setup('json-output-auth-mismatch', {
settings: {
@@ -50,38 +69,242 @@ describe('JSON output', () => {
} catch (e) {
thrown = e as Error;
} finally {
delete process.env['OPENAI_API_KEY'];
process.env['OPENAI_API_KEY'] = originalOpenaiApiKey;
}
expect(thrown).toBeDefined();
const message = (thrown as Error).message;
// Use a regex to find the first complete JSON object in the string
const jsonMatch = message.match(/{[\s\S]*}/);
// Fail if no JSON-like text was found
// The error JSON is written to stdout as a CLIResultMessageError
// Extract stdout from the error message
const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/);
expect(
jsonMatch,
'Expected to find a JSON object in the error output',
stdoutMatch,
'Expected to find stdout in the error message',
).toBeTruthy();
let payload;
const stdout = stdoutMatch![1];
let parsed: unknown[];
try {
// Parse the matched JSON string
payload = JSON.parse(jsonMatch![0]);
// Parse the JSON array from stdout
parsed = JSON.parse(stdout);
} catch (parseError) {
console.error('Failed to parse the following JSON:', jsonMatch![0]);
console.error('Failed to parse the following JSON:', stdout);
throw new Error(
`Test failed: Could not parse JSON from error message. Details: ${parseError}`,
`Test failed: Could not parse JSON from stdout. Details: ${parseError}`,
);
}
expect(payload.error).toBeDefined();
expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain(
// The output should be an array of messages
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBeGreaterThan(0);
// Find the result message with error
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result' &&
'is_error' in msg &&
msg.is_error === true,
) as {
type: string;
is_error: boolean;
subtype: string;
error?: { message: string; type?: string };
};
expect(resultMessage).toBeDefined();
expect(resultMessage.is_error).toBe(true);
expect(resultMessage).toHaveProperty('subtype');
expect(resultMessage.subtype).toBe('error_during_execution');
expect(resultMessage).toHaveProperty('error');
expect(resultMessage.error).toBeDefined();
expect(resultMessage.error?.message).toContain(
'configured auth type is qwen-oauth',
);
expect(payload.error.message).toContain('current auth type is openai');
expect(resultMessage.error?.message).toContain(
'current auth type is openai',
);
});
it('should return line-delimited JSON messages for stream-json output format', async () => {
const result = await rig.run(
'What is the capital of France?',
'--output-format',
'stream-json',
);
// Stream-json output is line-delimited JSON (one JSON object per line)
const lines = result
.trim()
.split('\n')
.filter((line) => line.trim());
expect(lines.length).toBeGreaterThan(0);
// Parse each line as a JSON object
const messages: unknown[] = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line);
messages.push(parsed);
} catch (parseError) {
throw new Error(
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
);
}
}
// Should have at least system, assistant, and result messages
expect(messages.length).toBeGreaterThanOrEqual(3);
// Find system message
const systemMessage = messages.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'system',
);
expect(systemMessage).toBeDefined();
expect(systemMessage).toHaveProperty('subtype');
expect(systemMessage).toHaveProperty('session_id');
// Find assistant message
const assistantMessage = messages.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'assistant',
);
expect(assistantMessage).toBeDefined();
expect(assistantMessage).toHaveProperty('message');
expect(assistantMessage).toHaveProperty('session_id');
// Find result message (should be the last message)
const resultMessage = messages[messages.length - 1] as {
type: string;
is_error: boolean;
result: string;
};
expect(resultMessage).toBeDefined();
expect(
typeof resultMessage === 'object' &&
resultMessage !== null &&
'type' in resultMessage &&
resultMessage.type === 'result',
).toBe(true);
expect(resultMessage).toHaveProperty('is_error');
expect(resultMessage.is_error).toBe(false);
expect(resultMessage).toHaveProperty('result');
expect(typeof resultMessage.result).toBe('string');
expect(resultMessage.result.toLowerCase()).toContain('paris');
});
it('should include stream events when using stream-json with include-partial-messages', async () => {
const result = await rig.run(
'What is the capital of France?',
'--output-format',
'stream-json',
'--include-partial-messages',
);
// Stream-json output is line-delimited JSON (one JSON object per line)
const lines = result
.trim()
.split('\n')
.filter((line) => line.trim());
expect(lines.length).toBeGreaterThan(0);
// Parse each line as a JSON object
const messages: unknown[] = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line);
messages.push(parsed);
} catch (parseError) {
throw new Error(
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
);
}
}
// Should have more messages than without include-partial-messages
// because we're including stream events
expect(messages.length).toBeGreaterThan(3);
// Find stream_event messages
const streamEvents = messages.filter(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'stream_event',
);
expect(streamEvents.length).toBeGreaterThan(0);
// Verify stream event structure
const firstStreamEvent = streamEvents[0];
expect(firstStreamEvent).toHaveProperty('event');
expect(firstStreamEvent).toHaveProperty('session_id');
expect(firstStreamEvent).toHaveProperty('uuid');
// Check for expected stream event types
const eventTypes = streamEvents.map((event: unknown) =>
typeof event === 'object' &&
event !== null &&
'event' in event &&
typeof event.event === 'object' &&
event.event !== null &&
'type' in event.event
? event.event.type
: null,
);
// Should have message_start event
expect(eventTypes).toContain('message_start');
// Should have content_block_start event
expect(eventTypes).toContain('content_block_start');
// Should have content_block_delta events
expect(eventTypes).toContain('content_block_delta');
// Should have content_block_stop event
expect(eventTypes).toContain('content_block_stop');
// Should have message_stop event
expect(eventTypes).toContain('message_stop');
// Verify that we still have the complete assistant message
const assistantMessage = messages.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'assistant',
);
expect(assistantMessage).toBeDefined();
expect(assistantMessage).toHaveProperty('message');
// Verify that we still have the result message
const resultMessage = messages[messages.length - 1] as {
type: string;
is_error: boolean;
result: string;
};
expect(resultMessage).toBeDefined();
expect(
typeof resultMessage === 'object' &&
resultMessage !== null &&
'type' in resultMessage &&
resultMessage.type === 'result',
).toBe(true);
expect(resultMessage).toHaveProperty('is_error');
expect(resultMessage.is_error).toBe(false);
expect(resultMessage).toHaveProperty('result');
expect(resultMessage.result.toLowerCase()).toContain('paris');
});
});

View File

@@ -340,7 +340,8 @@ export class TestRig {
// as it would corrupt the JSON
const isJsonOutput =
commandArgs.includes('--output-format') &&
commandArgs.includes('json');
(commandArgs.includes('json') ||
commandArgs.includes('stream-json'));
// If we have stderr output and it's not a JSON test, include that also
if (stderr && !isJsonOutput) {
@@ -348,9 +349,25 @@ export class TestRig {
}
resolve(result);
} else {
// Check if this is a JSON output test - for JSON errors, the error is in stdout
const isJsonOutputOnError =
commandArgs.includes('--output-format') &&
(commandArgs.includes('json') ||
commandArgs.includes('stream-json'));
// For JSON output tests, include stdout in the error message
// as the error JSON is written to stdout
if (isJsonOutputOnError && stdout) {
reject(
new Error(
`Process exited with code ${code}:\nStdout:\n${stdout}\n\nStderr:\n${stderr}`,
),
);
} else {
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
}
}
});
});

View File

@@ -9,14 +9,53 @@ import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('web_search', () => {
it('should be able to search the web', async () => {
// Skip if Tavily key is not configured
if (!process.env['TAVILY_API_KEY']) {
console.warn('Skipping web search test: TAVILY_API_KEY not set');
// Check if any web search provider is available
const hasTavilyKey = !!process.env['TAVILY_API_KEY'];
const hasGoogleKey =
!!process.env['GOOGLE_API_KEY'] &&
!!process.env['GOOGLE_SEARCH_ENGINE_ID'];
// Skip if no provider is configured
// Note: DashScope provider is automatically available for Qwen OAuth users,
// but we can't easily detect that in tests without actual OAuth credentials
if (!hasTavilyKey && !hasGoogleKey) {
console.warn(
'Skipping web search test: No web search provider configured. ' +
'Set TAVILY_API_KEY or GOOGLE_API_KEY+GOOGLE_SEARCH_ENGINE_ID environment variables.',
);
return;
}
const rig = new TestRig();
await rig.setup('should be able to search the web');
// Configure web search in settings if provider keys are available
const webSearchSettings: Record<string, unknown> = {};
const providers: Array<{
type: string;
apiKey?: string;
searchEngineId?: string;
}> = [];
if (hasTavilyKey) {
providers.push({ type: 'tavily', apiKey: process.env['TAVILY_API_KEY'] });
}
if (hasGoogleKey) {
providers.push({
type: 'google',
apiKey: process.env['GOOGLE_API_KEY'],
searchEngineId: process.env['GOOGLE_SEARCH_ENGINE_ID'],
});
}
if (providers.length > 0) {
webSearchSettings.webSearch = {
provider: providers,
default: providers[0]?.type,
};
}
await rig.setup('should be able to search the web', {
settings: webSearchSettings,
});
let result;
try {

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.1.3",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.1.3",
"version": "0.3.0",
"workspaces": [
"packages/*"
],
@@ -16024,7 +16024,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.1.3",
"version": "0.3.0",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -16139,7 +16139,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.1.3",
"version": "0.3.0",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -16278,7 +16278,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.1.3",
"version": "0.3.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -16290,7 +16290,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.1.3",
"version": "0.3.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.1.3",
"version": "0.3.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -46,6 +46,7 @@
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"check-i18n": "npm run check-i18n --workspace=packages/cli",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run bundle",
"prepare:package": "node scripts/prepare-package.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.1.3",
"version": "0.3.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -8,9 +8,16 @@
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"qwen": "dist/index.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "node ../../scripts/build_package.js",
"start": "node dist/index.js",
@@ -19,13 +26,14 @@
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"check-i18n": "tsx ../../scripts/check-i18n.ts"
},
"files": [
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
},
"dependencies": {
"@google/genai": "1.16.0",

View File

@@ -392,6 +392,49 @@ describe('parseArguments', () => {
mockConsoleError.mockRestore();
});
it('should throw an error when include-partial-messages is used without stream-json output', async () => {
process.argv = ['node', 'script.js', '--include-partial-messages'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'--include-partial-messages requires --output-format stream-json',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
it('should parse stream-json formats and include-partial-messages flag', async () => {
process.argv = [
'node',
'script.js',
'--output-format',
'stream-json',
'--input-format',
'stream-json',
'--include-partial-messages',
];
const argv = await parseArguments({} as Settings);
expect(argv.outputFormat).toBe('stream-json');
expect(argv.inputFormat).toBe('stream-json');
expect(argv.includePartialMessages).toBe(true);
});
it('should allow --approval-mode without --yolo', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
const argv = await parseArguments({} as Settings);
@@ -473,6 +516,34 @@ describe('loadCliConfig', () => {
vi.restoreAllMocks();
});
it('should propagate stream-json formats to config', async () => {
process.argv = [
'node',
'script.js',
'--output-format',
'stream-json',
'--input-format',
'stream-json',
'--include-partial-messages',
];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(
settings,
[],
new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
argv.extensions,
),
'test-session',
argv,
);
expect(config.getOutputFormat()).toBe('stream-json');
expect(config.getInputFormat()).toBe('stream-json');
expect(config.getIncludePartialMessages()).toBe(true);
});
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
process.argv = ['node', 'script.js', '--show-memory-usage'];
const argv = await parseArguments({} as Settings);

View File

@@ -7,7 +7,6 @@
import type {
FileFilteringOptions,
MCPServerConfig,
OutputFormat,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import {
@@ -24,6 +23,9 @@ import {
WriteFileTool,
resolveTelemetrySettings,
FatalConfigError,
Storage,
InputFormat,
OutputFormat,
} from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
import yargs, { type Argv } from 'yargs';
@@ -42,6 +44,7 @@ import { mcpCommand } from '../commands/mcp.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import { buildWebSearchConfig } from './webSearch.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
@@ -113,13 +116,34 @@ export interface CliArgs {
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
openaiLoggingDir: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
googleApiKey: string | undefined;
googleSearchEngineId: string | undefined;
webSearchDefault: string | undefined;
screenReader: boolean | undefined;
vlmSwitchMode: string | undefined;
useSmartEdit: boolean | undefined;
inputFormat?: string | undefined;
outputFormat: string | undefined;
includePartialMessages?: boolean;
}
function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
if (!format) {
return undefined;
}
if (format === OutputFormat.STREAM_JSON) {
return OutputFormat.STREAM_JSON;
}
if (format === 'json' || format === OutputFormat.JSON) {
return OutputFormat.JSON;
}
return OutputFormat.TEXT;
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
@@ -313,6 +337,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-logging-dir', {
type: 'string',
description:
'Custom directory path for OpenAI API logs. Overrides settings files.',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
@@ -323,7 +352,20 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search functionality',
description: 'Tavily API key for web search',
})
.option('google-api-key', {
type: 'string',
description: 'Google Custom Search API key',
})
.option('google-search-engine-id', {
type: 'string',
description: 'Google Custom Search Engine ID',
})
.option('web-search-default', {
type: 'string',
description:
'Default web search provider (dashscope, tavily, google)',
})
.option('screen-reader', {
type: 'boolean',
@@ -336,11 +378,23 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.',
default: process.env['VLM_SWITCH_MODE'],
})
.option('input-format', {
type: 'string',
choices: ['text', 'stream-json'],
description: 'The format consumed from standard input.',
default: 'text',
})
.option('output-format', {
alias: 'o',
type: 'string',
description: 'The format of the CLI output.',
choices: ['text', 'json'],
choices: ['text', 'json', 'stream-json'],
})
.option('include-partial-messages', {
type: 'boolean',
description:
'Include partial assistant messages when using stream-json output.',
default: false,
})
.deprecateOption(
'show-memory-usage',
@@ -385,6 +439,18 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
if (argv['yolo'] && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
if (
argv['includePartialMessages'] &&
argv['outputFormat'] !== OutputFormat.STREAM_JSON
) {
return '--include-partial-messages requires --output-format stream-json';
}
if (
argv['inputFormat'] === 'stream-json' &&
argv['outputFormat'] !== OutputFormat.STREAM_JSON
) {
return '--input-format stream-json requires --output-format stream-json';
}
return true;
}),
)
@@ -537,6 +603,20 @@ export async function loadCliConfig(
(e) => e.contextFiles,
);
// Automatically load output-language.md if it exists
const outputLanguageFilePath = path.join(
Storage.getGlobalQwenDir(),
'output-language.md',
);
if (fs.existsSync(outputLanguageFilePath)) {
extensionContextFilePaths.push(outputLanguageFilePath);
if (debugMode) {
logger.debug(
`Found output-language.md, adding to context files: ${outputLanguageFilePath}`,
);
}
}
const fileService = new FileDiscoveryService(cwd);
const fileFiltering = {
@@ -565,6 +645,22 @@ export async function loadCliConfig(
let mcpServers = mergeMcpServers(settings, activeExtensions);
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
const argvOutputFormat = normalizeOutputFormat(
argv.outputFormat as string | OutputFormat | undefined,
);
const settingsOutputFormat = normalizeOutputFormat(settings.output?.format);
const outputFormat =
argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT;
const outputSettingsFormat: OutputFormat =
outputFormat === OutputFormat.STREAM_JSON
? settingsOutputFormat &&
settingsOutputFormat !== OutputFormat.STREAM_JSON
? settingsOutputFormat
: OutputFormat.TEXT
: (outputFormat as OutputFormat);
const includePartialMessages = Boolean(argv.includePartialMessages);
// Determine approval mode with backward compatibility
let approvalMode: ApprovalMode;
@@ -606,11 +702,31 @@ export async function loadCliConfig(
throw err;
}
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
// Interactive mode determination with priority:
// 1. If promptInteractive (-i flag) is provided, it is explicitly interactive
// 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive
// 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive
const hasQuery = !!argv.query;
const interactive =
!!argv.promptInteractive ||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
const hasPrompt = !!argv.prompt;
let interactive: boolean;
if (argv.promptInteractive) {
// Priority 1: Explicit -i flag means interactive
interactive = true;
} else if (
(outputFormat === OutputFormat.STREAM_JSON ||
outputFormat === OutputFormat.JSON) &&
(hasQuery || hasPrompt)
) {
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
interactive = false;
} else if (!hasQuery && !hasPrompt) {
// Priority 3: No query or prompt means interactive only if TTY (format arguments ignored)
interactive = process.stdin.isTTY ?? false;
} else {
// Default: If we have query/prompt but output format is TEXT, assume non-interactive
// (fallback for edge cases where query/prompt is provided with TEXT output)
interactive = false;
}
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive && !argv.experimentalAcp) {
@@ -732,6 +848,9 @@ export async function loadCliConfig(
blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'],
authType: settings.security?.auth?.selectedType,
inputFormat,
outputFormat,
includePartialMessages,
generationConfig: {
...(settings.model?.generationConfig || {}),
model: resolvedModel,
@@ -747,12 +866,15 @@ export async function loadCliConfig(
(typeof argv.openaiLogging === 'undefined'
? settings.model?.enableOpenAILogging
: argv.openaiLogging) ?? false,
openAILoggingDir:
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
},
cliVersion: await getCliVersion(),
tavilyApiKey:
argv.tavilyApiKey ||
settings.advanced?.tavilyApiKey ||
process.env['TAVILY_API_KEY'],
webSearch: buildWebSearchConfig(
argv,
settings,
settings.security?.auth?.selectedType,
),
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
chatCompression: settings.model?.chatCompression,
@@ -763,8 +885,8 @@ export async function loadCliConfig(
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
skipStartupContext: settings.model?.skipStartupContext ?? false,
vlmSwitchMode,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
@@ -772,7 +894,7 @@ export async function loadCliConfig(
eventEmitter: appEvents,
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
format: outputSettingsFormat,
},
});
}

View File

@@ -77,7 +77,6 @@ const MIGRATION_MAP: Record<string, string> = {
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enablePromptCompletion: 'general.enablePromptCompletion',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded',
@@ -131,6 +130,7 @@ const MIGRATION_MAP: Record<string, string> = {
sessionTokenLimit: 'model.sessionTokenLimit',
contentGenerator: 'model.generationConfig',
skipLoopDetection: 'model.skipLoopDetection',
skipStartupContext: 'model.skipStartupContext',
enableOpenAILogging: 'model.enableOpenAILogging',
tavilyApiKey: 'advanced.tavilyApiKey',
vlmSwitchMode: 'experimental.vlmSwitchMode',
@@ -483,6 +483,27 @@ export class LoadedSettings {
}
}
/**
* Creates a minimal LoadedSettings instance with empty settings.
* Used in stream-json mode where settings are ignored.
*/
export function createMinimalSettings(): LoadedSettings {
const emptySettingsFile: SettingsFile = {
path: '',
settings: {},
originalSettings: {},
rawJson: '{}',
};
return new LoadedSettings(
emptySettingsFile,
emptySettingsFile,
emptySettingsFile,
emptySettingsFile,
false,
new Set(),
);
}
function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {
@@ -838,5 +859,6 @@ export function saveSettings(settingsFile: SettingsFile): void {
);
} catch (error) {
console.error('Error saving user settings file:', error);
throw error;
}
}

View File

@@ -12,6 +12,7 @@ import type {
ChatCompressionSettings,
} from '@qwen-code/qwen-code-core';
import {
ApprovalMode,
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
} from '@qwen-code/qwen-code-core';
@@ -166,16 +167,6 @@ const SETTINGS_SCHEMA = {
},
},
},
enablePromptCompletion: {
type: 'boolean',
label: 'Enable Prompt Completion',
category: 'General',
requiresRestart: true,
default: false,
description:
'Enable AI-powered prompt completion suggestions while typing.',
showInDialog: true,
},
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
@@ -185,6 +176,23 @@ const SETTINGS_SCHEMA = {
description: 'Enable debug logging of keystrokes to the console.',
showInDialog: true,
},
language: {
type: 'enum',
label: 'Language',
category: 'General',
requiresRestart: false,
default: 'auto',
description:
'The language for the user interface. Use "auto" to detect from system settings. ' +
'You can also use custom language codes (e.g., "es", "fr") by placing JS language files ' +
'in ~/.qwen/locales/ (e.g., ~/.qwen/locales/es.js).',
showInDialog: true,
options: [
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
],
},
},
},
output: {
@@ -549,6 +557,16 @@ const SETTINGS_SCHEMA = {
description: 'Disable all loop detection checks (streaming and LLM).',
showInDialog: true,
},
skipStartupContext: {
type: 'boolean',
label: 'Skip Startup Context',
category: 'Model',
requiresRestart: true,
default: false,
description:
'Avoid sending the workspace startup context at the beginning of each session.',
showInDialog: true,
},
enableOpenAILogging: {
type: 'boolean',
label: 'Enable OpenAI Logging',
@@ -558,6 +576,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable OpenAI logging.',
showInDialog: true,
},
openAILoggingDir: {
type: 'string',
label: 'OpenAI Logging Directory',
category: 'Model',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.',
showInDialog: true,
},
generationConfig: {
type: 'object',
label: 'Generation Configuration',
@@ -810,14 +838,20 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.UNION,
},
approvalMode: {
type: 'string',
label: 'Default Approval Mode',
type: 'enum',
label: 'Approval Mode',
category: 'Tools',
requiresRestart: false,
default: 'default',
default: ApprovalMode.DEFAULT,
description:
'Default approval mode for tool usage. Valid values: plan, default, auto-edit, yolo.',
'Approval mode for tool usage. Controls how tools are approved before execution.',
showInDialog: true,
options: [
{ value: ApprovalMode.PLAN, label: 'Plan' },
{ value: ApprovalMode.DEFAULT, label: 'Default' },
{ value: ApprovalMode.AUTO_EDIT, label: 'Auto Edit' },
{ value: ApprovalMode.YOLO, label: 'YOLO' },
],
},
discoveryCommand: {
type: 'string',
@@ -1072,17 +1106,36 @@ const SETTINGS_SCHEMA = {
},
tavilyApiKey: {
type: 'string',
label: 'Tavily API Key',
label: 'Tavily API Key (Deprecated)',
category: 'Advanced',
requiresRestart: false,
default: undefined as string | undefined,
description:
'The API key for the Tavily API. Required to enable the web_search tool functionality.',
'⚠️ DEPRECATED: Please use webSearch.provider configuration instead. Legacy API key for the Tavily API.',
showInDialog: false,
},
},
},
webSearch: {
type: 'object',
label: 'Web Search',
category: 'Advanced',
requiresRestart: true,
default: undefined as
| {
provider: Array<{
type: 'tavily' | 'google' | 'dashscope';
apiKey?: string;
searchEngineId?: string;
}>;
default: string;
}
| undefined,
description: 'Configuration for web search providers.',
showInDialog: false,
},
experimental: {
type: 'object',
label: 'Experimental',

View File

@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@qwen-code/qwen-code-core';
import type { WebSearchProviderConfig } from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
/**
* CLI arguments related to web search configuration
*/
export interface WebSearchCliArgs {
tavilyApiKey?: string;
googleApiKey?: string;
googleSearchEngineId?: string;
webSearchDefault?: string;
}
/**
* Web search configuration structure
*/
export interface WebSearchConfig {
provider: WebSearchProviderConfig[];
default: string;
}
/**
* Build webSearch configuration from multiple sources with priority:
* 1. settings.json (new format) - highest priority
* 2. Command line args + environment variables
* 3. Legacy tavilyApiKey (backward compatibility)
*
* @param argv - Command line arguments
* @param settings - User settings from settings.json
* @param authType - Authentication type (e.g., 'qwen-oauth')
* @returns WebSearch configuration or undefined if no providers available
*/
export function buildWebSearchConfig(
argv: WebSearchCliArgs,
settings: Settings,
authType?: string,
): WebSearchConfig | undefined {
const isQwenOAuth = authType === AuthType.QWEN_OAUTH;
// Step 1: Collect providers from settings or command line/env
let providers: WebSearchProviderConfig[] = [];
let userDefault: string | undefined;
if (settings.webSearch) {
// Use providers from settings.json
providers = [...settings.webSearch.provider];
userDefault = settings.webSearch.default;
} else {
// Build providers from command line args and environment variables
const tavilyKey =
argv.tavilyApiKey ||
settings.advanced?.tavilyApiKey ||
process.env['TAVILY_API_KEY'];
if (tavilyKey) {
providers.push({
type: 'tavily',
apiKey: tavilyKey,
} as WebSearchProviderConfig);
}
const googleKey = argv.googleApiKey || process.env['GOOGLE_API_KEY'];
const googleEngineId =
argv.googleSearchEngineId || process.env['GOOGLE_SEARCH_ENGINE_ID'];
if (googleKey && googleEngineId) {
providers.push({
type: 'google',
apiKey: googleKey,
searchEngineId: googleEngineId,
} as WebSearchProviderConfig);
}
}
// Step 2: Ensure dashscope is available for qwen-oauth users
if (isQwenOAuth) {
const hasDashscope = providers.some((p) => p.type === 'dashscope');
if (!hasDashscope) {
providers.push({ type: 'dashscope' } as WebSearchProviderConfig);
}
}
// Step 3: If no providers available, return undefined
if (providers.length === 0) {
return undefined;
}
// Step 4: Determine default provider
// Priority: user explicit config > CLI arg > first available provider (tavily > google > dashscope)
const providerPriority: Array<'tavily' | 'google' | 'dashscope'> = [
'tavily',
'google',
'dashscope',
];
// Determine default provider based on availability
let defaultProvider = userDefault || argv.webSearchDefault;
if (!defaultProvider) {
// Find first available provider by priority order
for (const providerType of providerPriority) {
if (providers.some((p) => p.type === providerType)) {
defaultProvider = providerType;
break;
}
}
// Fallback to first available provider if none found in priority list
if (!defaultProvider) {
defaultProvider = providers[0]?.type || 'dashscope';
}
}
return {
provider: providers,
default: defaultProvider,
};
}

View File

@@ -8,6 +8,8 @@ import {
type AuthType,
type Config,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
/**
@@ -25,11 +27,21 @@ export async function performInitialAuth(
}
try {
await config.refreshAuth(authType);
await config.refreshAuth(authType, true);
// The console.log is intentionally left out here.
// We can add a dedicated startup message later if needed.
// Log authentication success
const authEvent = new AuthEvent(authType, 'auto', 'success');
logAuth(config, authEvent);
} catch (e) {
return `Failed to login. Message: ${getErrorMessage(e)}`;
const errorMessage = `Failed to login. Message: ${getErrorMessage(e)}`;
// Log authentication failure
const authEvent = new AuthEvent(authType, 'auto', 'error', errorMessage);
logAuth(config, authEvent);
return errorMessage;
}
return null;

View File

@@ -11,9 +11,10 @@ import {
logIdeConnection,
type Config,
} from '@qwen-code/qwen-code-core';
import { type LoadedSettings } from '../config/settings.js';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import { initializeI18n } from '../i18n/index.js';
export interface InitializationResult {
authError: string | null;
@@ -33,10 +34,24 @@ export async function initializeApp(
config: Config,
settings: LoadedSettings,
): Promise<InitializationResult> {
const authError = await performInitialAuth(
config,
settings.merged.security?.auth?.selectedType,
// Initialize i18n system
const languageSetting =
process.env['QWEN_CODE_LANG'] ||
settings.merged.general?.language ||
'auto';
await initializeI18n(languageSetting);
const authType = settings.merged.security?.auth?.selectedType;
const authError = await performInitialAuth(config, authType);
// Fallback to user select when initial authentication fails
if (authError) {
settings.setValue(
SettingScope.User,
'security.auth.selectedType',
undefined,
);
}
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =

View File

@@ -6,6 +6,7 @@
import { themeManager } from '../ui/themes/theme-manager.js';
import { type LoadedSettings } from '../config/settings.js';
import { t } from '../i18n/index.js';
/**
* Validates the configured theme.
@@ -15,7 +16,9 @@ import { type LoadedSettings } from '../config/settings.js';
export function validateTheme(settings: LoadedSettings): string | null {
const effectiveTheme = settings.merged.ui?.theme;
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
return `Theme "${effectiveTheme}" not found.`;
return t('Theme "{{themeName}}" not found.', {
themeName: effectiveTheme,
});
}
return null;
}

View File

@@ -22,6 +22,7 @@ import {
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { OutputFormat } from '@qwen-code/qwen-code-core';
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
@@ -158,6 +159,7 @@ describe('gemini.tsx main function', () => {
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
} as unknown as Config;
});
vi.mocked(loadSettings).mockReturnValue({
@@ -230,6 +232,143 @@ describe('gemini.tsx main function', () => {
// Avoid the process.exit error from being thrown.
processExitSpy.mockRestore();
});
it('invokes runNonInteractiveStreamJson and performs cleanup in stream-json mode', async () => {
const originalIsTTY = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY',
);
const originalIsRaw = Object.getOwnPropertyDescriptor(
process.stdin,
'isRaw',
);
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
Object.defineProperty(process.stdin, 'isRaw', {
value: false,
configurable: true,
});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const extensionModule = await import('./config/extension.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const streamJsonModule = await import('./nonInteractive/session.js');
const initializerModule = await import('./core/initializer.js');
const startupWarningsModule = await import('./utils/startupWarnings.js');
const userStartupWarningsModule = await import(
'./utils/userStartupWarnings.js'
);
vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]);
vi.spyOn(
extensionModule.ExtensionStorage,
'getUserExtensionsDir',
).mockReturnValue('/tmp/extensions');
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
});
vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);
vi.spyOn(
userStartupWarningsModule,
'getUserStartupWarnings',
).mockResolvedValue([]);
const validatedConfig = { validated: true } as unknown as Config;
const validateAuthSpy = vi
.spyOn(validatorModule, 'validateNonInteractiveAuth')
.mockResolvedValue(validatedConfig);
const runStreamJsonSpy = vi
.spyOn(streamJsonModule, 'runNonInteractiveStreamJson')
.mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
} as never);
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as never);
const configStub = {
isInteractive: () => false,
getQuestion: () => ' hello stream ',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
initialize: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getInputFormat: () => 'stream-json',
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
} as unknown as Config;
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
process.env['SANDBOX'] = '1';
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
} finally {
processExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
if (originalIsRaw) {
Object.defineProperty(process.stdin, 'isRaw', originalIsRaw);
} else {
delete (process.stdin as { isRaw?: unknown }).isRaw;
}
delete process.env['SANDBOX'];
}
expect(runStreamJsonSpy).toHaveBeenCalledTimes(1);
const [configArg, inputArg] = runStreamJsonSpy.mock.calls[0];
expect(configArg).toBe(validatedConfig);
expect(inputArg).toBe('hello stream');
expect(validateAuthSpy).toHaveBeenCalledWith(
undefined,
undefined,
configStub,
expect.any(Object),
);
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
});
});
describe('gemini.tsx main function kitty protocol', () => {
@@ -327,13 +466,19 @@ describe('gemini.tsx main function kitty protocol', () => {
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,
googleApiKey: undefined,
googleSearchEngineId: undefined,
webSearchDefault: undefined,
screenReader: undefined,
vlmSwitchMode: undefined,
useSmartEdit: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
});
await main();
@@ -408,6 +553,7 @@ describe('startInteractiveUI', () => {
vi.mock('./utils/cleanup.js', () => ({
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
registerCleanup: vi.fn(),
runExitCleanup: vi.fn(() => Promise.resolve()),
}));
vi.mock('ink', () => ({

View File

@@ -4,58 +4,60 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type { Config } from '@qwen-code/qwen-code-core';
import {
AuthType,
getOauthClient,
InputFormat,
logUserPrompt,
} from '@qwen-code/qwen-code-core';
import { render } from 'ink';
import { AppContainer } from './ui/AppContainer.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import * as cliConfig from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { randomUUID } from 'node:crypto';
import dns from 'node:dns';
import os from 'node:os';
import { basename } from 'node:path';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import { randomUUID } from 'node:crypto';
import { start_sandbox } from './utils/sandbox.js';
import React from 'react';
import { validateAuthMethod } from './config/auth.js';
import * as cliConfig from './config/config.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { ExtensionStorage, loadExtensions } from './config/extension.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import {
initializeApp,
type InitializationResult,
} from './core/initializer.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import { ExtensionStorage, loadExtensions } from './config/extension.js';
import { runNonInteractiveStreamJson } from './nonInteractive/session.js';
import { AppContainer } from './ui/AppContainer.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import {
cleanupCheckpoints,
registerCleanup,
runExitCleanup,
} from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
AuthType,
getOauthClient,
logUserPrompt,
} from '@qwen-code/qwen-code-core';
import {
initializeApp,
type InitializationResult,
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { AppEvent, appEvents } from './utils/events.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { appEvents, AppEvent } from './utils/events.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { readStdin } from './utils/readStdin.js';
import {
relaunchOnExitCode,
relaunchAppInChildProcess,
relaunchOnExitCode,
} from './utils/relaunch.js';
import { start_sandbox } from './utils/sandbox.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { getCliVersion } from './utils/version.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
export function validateDnsResolutionOrder(
@@ -106,9 +108,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
return [];
}
import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
export function setupUnhandledRejectionHandler() {
let unhandledRejectionOccurred = false;
@@ -218,12 +220,6 @@ export async function main() {
}
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: isDebugMode,
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
@@ -348,6 +344,15 @@ export async function main() {
process.exit(0);
}
// Setup unified ConsolePatcher based on interactive mode
const isInteractive = config.isInteractive();
const consolePatcher = new ConsolePatcher({
stderr: isInteractive,
debugMode: isDebugMode,
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
const wasRaw = process.stdin.isRaw;
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
@@ -410,14 +415,43 @@ export async function main() {
await config.initialize();
// If not a TTY, read from stdin
// This is for cases where the user pipes input directly into the command
if (!process.stdin.isTTY) {
// Check input format BEFORE reading stdin
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
const inputFormat =
typeof config.getInputFormat === 'function'
? config.getInputFormat()
: InputFormat.TEXT;
// Only read stdin if NOT in stream-json mode
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
// and should be consumed by StreamJsonInputReader instead
if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) {
const stdinData = await readStdin();
if (stdinData) {
input = `${stdinData}\n\n${input}`;
}
}
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
config,
settings,
);
const prompt_id = Math.random().toString(16).slice(2);
if (inputFormat === InputFormat.STREAM_JSON) {
const trimmedInput = (input ?? '').trim();
await runNonInteractiveStreamJson(
nonInteractiveConfig,
trimmedInput.length > 0 ? trimmedInput : '',
);
await runExitCleanup();
process.exit(0);
}
if (!input) {
console.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
@@ -425,7 +459,6 @@ export async function main() {
process.exit(1);
}
const prompt_id = Math.random().toString(16).slice(2);
logUserPrompt(config, {
'event.name': 'user_prompt',
'event.timestamp': new Date().toISOString(),
@@ -435,13 +468,6 @@ export async function main() {
prompt_length: input.length,
});
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
config,
settings,
);
if (config.getDebugMode()) {
console.log('Session ID: %s', sessionId);
}

View File

@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
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
// State
let currentLanguage: SupportedLanguage = 'en';
let translations: Record<string, string> = {};
// Cache
type TranslationDict = Record<string, string>;
const translationCache: Record<string, TranslationDict> = {};
const loadingPromises: Record<string, Promise<TranslationDict>> = {};
// Path helpers
const getBuiltinLocalesDir = (): string => {
const __filename = fileURLToPath(import.meta.url);
return path.join(path.dirname(__filename), 'locales');
};
const getUserLocalesDir = (): string =>
path.join(homedir(), '.qwen', 'locales');
/**
* Get the path to the user's custom locales directory.
* Users can place custom language packs (e.g., es.js, fr.js) in this directory.
* @returns The path to ~/.qwen/locales
*/
export function getUserLocalesDirectory(): string {
return getUserLocalesDir();
}
const getLocalePath = (
lang: SupportedLanguage,
useUserDir: boolean = false,
): string => {
const baseDir = useUserDir ? getUserLocalesDir() : getBuiltinLocalesDir();
return path.join(baseDir, `${lang}.js`);
};
// Language detection
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';
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh';
} catch {
// Fallback to default
}
return 'en';
}
// Translation loading
async function loadTranslationsAsync(
lang: SupportedLanguage,
): Promise<TranslationDict> {
if (translationCache[lang]) {
return translationCache[lang];
}
const existingPromise = loadingPromises[lang];
if (existingPromise) {
return existingPromise;
}
const loadPromise = (async () => {
// Try user directory first (for custom language packs), then builtin directory
const searchDirs = [
{ dir: getUserLocalesDir(), isUser: true },
{ dir: getBuiltinLocalesDir(), isUser: false },
];
for (const { dir, isUser } of searchDirs) {
// Ensure directory exists
if (!fs.existsSync(dir)) {
continue;
}
const jsPath = getLocalePath(lang, isUser);
if (!fs.existsSync(jsPath)) {
continue;
}
try {
// Convert file path to file:// URL for cross-platform compatibility
const fileUrl = pathToFileURL(jsPath).href;
try {
const module = await import(fileUrl);
const result = module.default || module;
if (
result &&
typeof result === 'object' &&
Object.keys(result).length > 0
) {
translationCache[lang] = result;
return result;
} else {
throw new Error('Module loaded but result is empty or invalid');
}
} catch {
// For builtin locales, try alternative import method (relative path)
if (!isUser) {
try {
const module = await import(`./locales/${lang}.js`);
const result = module.default || module;
if (
result &&
typeof result === 'object' &&
Object.keys(result).length > 0
) {
translationCache[lang] = result;
return result;
}
} catch {
// Continue to next directory
}
}
// If import failed, continue to next directory
continue;
}
} catch (error) {
// Log warning but continue to next directory
if (isUser) {
console.warn(
`Failed to load translations from user directory for ${lang}:`,
error,
);
} else {
console.warn(`Failed to load JS translations for ${lang}:`, error);
if (error instanceof Error) {
console.warn(`Error details: ${error.message}`);
console.warn(`Stack: ${error.stack}`);
}
}
// Continue to next directory
continue;
}
}
// Return empty object if both directories fail
// Cache it to avoid repeated failed attempts
translationCache[lang] = {};
return {};
})();
loadingPromises[lang] = loadPromise;
// Clean up promise after completion to allow retry on next call if needed
loadPromise.finally(() => {
delete loadingPromises[lang];
});
return loadPromise;
}
function loadTranslations(lang: SupportedLanguage): TranslationDict {
// Only return from cache (JS files require async loading)
return translationCache[lang] || {};
}
// String interpolation
function interpolate(
template: string,
params?: Record<string, string>,
): string {
if (!params) return template;
return template.replace(
/\{\{(\w+)\}\}/g,
(match, key) => params[key] ?? match,
);
}
// Language setting helpers
function resolveLanguage(lang: SupportedLanguage | 'auto'): SupportedLanguage {
return lang === 'auto' ? detectSystemLanguage() : lang;
}
// Public API
export function setLanguage(lang: SupportedLanguage | 'auto'): void {
const resolvedLang = resolveLanguage(lang);
currentLanguage = resolvedLang;
// Try to load translations synchronously (from cache only)
const loaded = loadTranslations(resolvedLang);
translations = loaded;
// Warn if translations are empty and JS file exists (requires async loading)
if (Object.keys(loaded).length === 0) {
const userJsPath = getLocalePath(resolvedLang, true);
const builtinJsPath = getLocalePath(resolvedLang, false);
if (fs.existsSync(userJsPath) || fs.existsSync(builtinJsPath)) {
console.warn(
`Language file for ${resolvedLang} requires async loading. ` +
`Use setLanguageAsync() instead, or call initializeI18n() first.`,
);
}
}
}
export async function setLanguageAsync(
lang: SupportedLanguage | 'auto',
): Promise<void> {
currentLanguage = resolveLanguage(lang);
translations = await loadTranslationsAsync(currentLanguage);
}
export function getCurrentLanguage(): SupportedLanguage {
return currentLanguage;
}
export function t(key: string, params?: Record<string, string>): string {
const translation = translations[key] ?? key;
return interpolate(translation, params);
}
export async function initializeI18n(
lang?: SupportedLanguage | 'auto',
): Promise<void> {
await setLanguageAsync(lang ?? 'auto');
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Control Context
*
* Layer 1 of the control plane architecture. Provides shared, session-scoped
* state for all controllers and services, eliminating the need for prop
* drilling. Mutable fields are intentionally exposed so controllers can track
* runtime state (e.g. permission mode, active MCP clients).
*/
import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
import type { PermissionMode } from '../types.js';
/**
* Control Context interface
*
* Provides shared access to session-scoped resources and mutable state
* for all controllers across both ControlDispatcher (protocol routing) and
* ControlService (programmatic API).
*/
export interface IControlContext {
readonly config: Config;
readonly streamJson: StreamJsonOutputAdapter;
readonly sessionId: string;
readonly abortSignal: AbortSignal;
readonly debugMode: boolean;
permissionMode: PermissionMode;
sdkMcpServers: Set<string>;
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
onInterrupt?: () => void;
}
/**
* Control Context implementation
*/
export class ControlContext implements IControlContext {
readonly config: Config;
readonly streamJson: StreamJsonOutputAdapter;
readonly sessionId: string;
readonly abortSignal: AbortSignal;
readonly debugMode: boolean;
permissionMode: PermissionMode;
sdkMcpServers: Set<string>;
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
onInterrupt?: () => void;
constructor(options: {
config: Config;
streamJson: StreamJsonOutputAdapter;
sessionId: string;
abortSignal: AbortSignal;
permissionMode?: PermissionMode;
onInterrupt?: () => void;
}) {
this.config = options.config;
this.streamJson = options.streamJson;
this.sessionId = options.sessionId;
this.abortSignal = options.abortSignal;
this.debugMode = options.config.getDebugMode();
this.permissionMode = options.permissionMode || 'default';
this.sdkMcpServers = new Set();
this.mcpClients = new Map();
this.onInterrupt = options.onInterrupt;
}
}

View File

@@ -0,0 +1,924 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ControlDispatcher } from './ControlDispatcher.js';
import type { IControlContext } from './ControlContext.js';
import type { SystemController } from './controllers/systemController.js';
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
import type {
CLIControlRequest,
CLIControlResponse,
ControlResponse,
ControlRequestPayload,
CLIControlInitializeRequest,
CLIControlInterruptRequest,
CLIControlSetModelRequest,
CLIControlSupportedCommandsRequest,
} from '../types.js';
/**
* Creates a mock control context for testing
*/
function createMockContext(debugMode: boolean = false): IControlContext {
const abortController = new AbortController();
const mockStreamJson = {
send: vi.fn(),
} as unknown as StreamJsonOutputAdapter;
const mockConfig = {
getDebugMode: vi.fn().mockReturnValue(debugMode),
};
return {
config: mockConfig as unknown as IControlContext['config'],
streamJson: mockStreamJson,
sessionId: 'test-session-id',
abortSignal: abortController.signal,
debugMode,
permissionMode: 'default',
sdkMcpServers: new Set<string>(),
mcpClients: new Map(),
};
}
/**
* Creates a mock system controller for testing
*/
function createMockSystemController() {
return {
handleRequest: vi.fn(),
sendControlRequest: vi.fn(),
cleanup: vi.fn(),
} as unknown as SystemController;
}
describe('ControlDispatcher', () => {
let dispatcher: ControlDispatcher;
let mockContext: IControlContext;
let mockSystemController: SystemController;
beforeEach(() => {
mockContext = createMockContext();
mockSystemController = createMockSystemController();
// Mock SystemController constructor
vi.doMock('./controllers/systemController.js', () => ({
SystemController: vi.fn().mockImplementation(() => mockSystemController),
}));
dispatcher = new ControlDispatcher(mockContext);
// Replace with mock controller for easier testing
(
dispatcher as unknown as { systemController: SystemController }
).systemController = mockSystemController;
});
describe('constructor', () => {
it('should initialize with context and create controllers', () => {
expect(dispatcher).toBeDefined();
expect(dispatcher.systemController).toBeDefined();
});
it('should listen to abort signal and shutdown when aborted', () => {
const abortController = new AbortController();
const context = {
...createMockContext(),
abortSignal: abortController.signal,
};
const newDispatcher = new ControlDispatcher(context);
vi.spyOn(newDispatcher, 'shutdown');
abortController.abort();
// Give event loop a chance to process
return new Promise<void>((resolve) => {
setImmediate(() => {
expect(newDispatcher.shutdown).toHaveBeenCalled();
resolve();
});
});
});
});
describe('dispatch', () => {
it('should route initialize request to system controller', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-1',
request: {
subtype: 'initialize',
} as CLIControlInitializeRequest,
};
const mockResponse = {
subtype: 'initialize',
capabilities: { test: true },
};
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
mockResponse,
);
await dispatcher.dispatch(request);
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
request.request,
'req-1',
);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-1',
response: mockResponse,
},
});
});
it('should route interrupt request to system controller', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-2',
request: {
subtype: 'interrupt',
} as CLIControlInterruptRequest,
};
const mockResponse = { subtype: 'interrupt' };
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
mockResponse,
);
await dispatcher.dispatch(request);
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
request.request,
'req-2',
);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-2',
response: mockResponse,
},
});
});
it('should route set_model request to system controller', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-3',
request: {
subtype: 'set_model',
model: 'test-model',
} as CLIControlSetModelRequest,
};
const mockResponse = {
subtype: 'set_model',
model: 'test-model',
};
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
mockResponse,
);
await dispatcher.dispatch(request);
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
request.request,
'req-3',
);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-3',
response: mockResponse,
},
});
});
it('should route supported_commands request to system controller', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-4',
request: {
subtype: 'supported_commands',
} as CLIControlSupportedCommandsRequest,
};
const mockResponse = {
subtype: 'supported_commands',
commands: ['initialize', 'interrupt'],
};
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
mockResponse,
);
await dispatcher.dispatch(request);
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
request.request,
'req-4',
);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-4',
response: mockResponse,
},
});
});
it('should send error response when controller throws error', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-5',
request: {
subtype: 'initialize',
} as CLIControlInitializeRequest,
};
const error = new Error('Test error');
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(error);
await dispatcher.dispatch(request);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-5',
error: 'Test error',
},
});
});
it('should handle non-Error thrown values', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-6',
request: {
subtype: 'initialize',
} as CLIControlInitializeRequest,
};
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(
'String error',
);
await dispatcher.dispatch(request);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-6',
error: 'String error',
},
});
});
it('should send error response for unknown request subtype', async () => {
const request = {
type: 'control_request' as const,
request_id: 'req-7',
request: {
subtype: 'unknown_subtype',
} as unknown as ControlRequestPayload,
};
await dispatcher.dispatch(request);
// Dispatch catches errors and sends error response instead of throwing
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-7',
error: 'Unknown control request subtype: unknown_subtype',
},
});
});
});
describe('handleControlResponse', () => {
it('should resolve pending outgoing request on success response', () => {
const requestId = 'outgoing-req-1';
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: { result: 'success' },
},
};
// Register a pending outgoing request
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
// Access private method through type casting
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
dispatcher.handleControlResponse(response);
expect(resolve).toHaveBeenCalledWith(response.response);
expect(reject).not.toHaveBeenCalled();
});
it('should reject pending outgoing request on error response', () => {
const requestId = 'outgoing-req-2';
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error: 'Request failed',
},
};
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
dispatcher.handleControlResponse(response);
expect(reject).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Request failed',
}),
);
expect(resolve).not.toHaveBeenCalled();
});
it('should handle error object in error response', () => {
const requestId = 'outgoing-req-3';
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error: { message: 'Detailed error', code: 500 },
},
};
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
dispatcher.handleControlResponse(response);
expect(reject).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Detailed error',
}),
);
});
it('should handle response for non-existent pending request gracefully', () => {
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: 'non-existent',
response: {},
},
};
// Should not throw
expect(() => dispatcher.handleControlResponse(response)).not.toThrow();
});
it('should handle response for non-existent request in debug mode', () => {
const context = createMockContext(true);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const dispatcherWithDebug = new ControlDispatcher(context);
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: 'non-existent',
response: {},
},
};
dispatcherWithDebug.handleControlResponse(response);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[ControlDispatcher] No pending outgoing request for: non-existent',
),
);
consoleSpy.mockRestore();
});
});
describe('sendControlRequest', () => {
it('should delegate to system controller sendControlRequest', async () => {
const payload: ControlRequestPayload = {
subtype: 'initialize',
} as CLIControlInitializeRequest;
const expectedResponse: ControlResponse = {
subtype: 'success',
request_id: 'test-id',
response: {},
};
vi.mocked(mockSystemController.sendControlRequest).mockResolvedValue(
expectedResponse,
);
const result = await dispatcher.sendControlRequest(payload, 5000);
expect(mockSystemController.sendControlRequest).toHaveBeenCalledWith(
payload,
5000,
);
expect(result).toBe(expectedResponse);
});
});
describe('handleCancel', () => {
it('should cancel specific incoming request', () => {
const requestId = 'cancel-req-1';
const abortController = new AbortController();
const timeoutId = setTimeout(() => {}, 1000);
const abortSpy = vi.spyOn(abortController, 'abort');
(
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest(
requestId,
'SystemController',
abortController,
timeoutId,
);
dispatcher.handleCancel(requestId);
expect(abortSpy).toHaveBeenCalled();
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error: 'Request cancelled',
},
});
});
it('should cancel all incoming requests when no requestId provided', () => {
const requestId1 = 'cancel-req-2';
const requestId2 = 'cancel-req-3';
const abortController1 = new AbortController();
const abortController2 = new AbortController();
const timeoutId1 = setTimeout(() => {}, 1000);
const timeoutId2 = setTimeout(() => {}, 1000);
const abortSpy1 = vi.spyOn(abortController1, 'abort');
const abortSpy2 = vi.spyOn(abortController2, 'abort');
const register = (
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest.bind(dispatcher);
register(requestId1, 'SystemController', abortController1, timeoutId1);
register(requestId2, 'SystemController', abortController2, timeoutId2);
dispatcher.handleCancel();
expect(abortSpy1).toHaveBeenCalled();
expect(abortSpy2).toHaveBeenCalled();
expect(mockContext.streamJson.send).toHaveBeenCalledTimes(2);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId1,
error: 'All requests cancelled',
},
});
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId2,
error: 'All requests cancelled',
},
});
});
it('should handle cancel of non-existent request gracefully', () => {
expect(() => dispatcher.handleCancel('non-existent')).not.toThrow();
});
it('should log cancellation in debug mode', () => {
const context = createMockContext(true);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const dispatcherWithDebug = new ControlDispatcher(context);
const requestId = 'cancel-req-debug';
const abortController = new AbortController();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcherWithDebug as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest(
requestId,
'SystemController',
abortController,
timeoutId,
);
dispatcherWithDebug.handleCancel(requestId);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[ControlDispatcher] Cancelled incoming request: cancel-req-debug',
),
);
consoleSpy.mockRestore();
});
});
describe('shutdown', () => {
it('should cancel all pending incoming requests', () => {
const requestId1 = 'shutdown-req-1';
const requestId2 = 'shutdown-req-2';
const abortController1 = new AbortController();
const abortController2 = new AbortController();
const timeoutId1 = setTimeout(() => {}, 1000);
const timeoutId2 = setTimeout(() => {}, 1000);
const abortSpy1 = vi.spyOn(abortController1, 'abort');
const abortSpy2 = vi.spyOn(abortController2, 'abort');
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const register = (
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest.bind(dispatcher);
register(requestId1, 'SystemController', abortController1, timeoutId1);
register(requestId2, 'SystemController', abortController2, timeoutId2);
dispatcher.shutdown();
expect(abortSpy1).toHaveBeenCalled();
expect(abortSpy2).toHaveBeenCalled();
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
});
it('should reject all pending outgoing requests', () => {
const requestId1 = 'outgoing-shutdown-1';
const requestId2 = 'outgoing-shutdown-2';
const reject1 = vi.fn();
const reject2 = vi.fn();
const timeoutId1 = setTimeout(() => {}, 1000);
const timeoutId2 = setTimeout(() => {}, 1000);
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const register = (
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest.bind(dispatcher);
register(requestId1, 'SystemController', vi.fn(), reject1, timeoutId1);
register(requestId2, 'SystemController', vi.fn(), reject2, timeoutId2);
dispatcher.shutdown();
expect(reject1).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Dispatcher shutdown',
}),
);
expect(reject2).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Dispatcher shutdown',
}),
);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
});
it('should cleanup all controllers', () => {
vi.mocked(mockSystemController.cleanup).mockImplementation(() => {});
dispatcher.shutdown();
expect(mockSystemController.cleanup).toHaveBeenCalled();
});
it('should log shutdown in debug mode', () => {
const context = createMockContext(true);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const dispatcherWithDebug = new ControlDispatcher(context);
dispatcherWithDebug.shutdown();
expect(consoleSpy).toHaveBeenCalledWith(
'[ControlDispatcher] Shutting down',
);
consoleSpy.mockRestore();
});
});
describe('pending request registry', () => {
describe('registerIncomingRequest', () => {
it('should register incoming request', () => {
const requestId = 'reg-incoming-1';
const abortController = new AbortController();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest(
requestId,
'SystemController',
abortController,
timeoutId,
);
// Verify it was registered by trying to cancel it
dispatcher.handleCancel(requestId);
expect(abortController.signal.aborted).toBe(true);
});
});
describe('deregisterIncomingRequest', () => {
it('should deregister incoming request', () => {
const requestId = 'dereg-incoming-1';
const abortController = new AbortController();
const timeoutId = setTimeout(() => {}, 1000);
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
(
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
deregisterIncomingRequest: (id: string) => void;
}
).registerIncomingRequest(
requestId,
'SystemController',
abortController,
timeoutId,
);
(
dispatcher as unknown as {
deregisterIncomingRequest: (id: string) => void;
}
).deregisterIncomingRequest(requestId);
// Verify it was deregistered - cancel should not find it
const sendMock = vi.mocked(mockContext.streamJson.send);
const sendCallCount = sendMock.mock.calls.length;
dispatcher.handleCancel(requestId);
// Should not send cancel response for non-existent request
expect(sendMock.mock.calls.length).toBe(sendCallCount);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
});
it('should handle deregister of non-existent request gracefully', () => {
expect(() => {
(
dispatcher as unknown as {
deregisterIncomingRequest: (id: string) => void;
}
).deregisterIncomingRequest('non-existent');
}).not.toThrow();
});
});
describe('registerOutgoingRequest', () => {
it('should register outgoing request', () => {
const requestId = 'reg-outgoing-1';
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
// Verify it was registered by handling a response
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {},
},
};
dispatcher.handleControlResponse(response);
expect(resolve).toHaveBeenCalled();
});
});
describe('deregisterOutgoingRequest', () => {
it('should deregister outgoing request', () => {
const requestId = 'dereg-outgoing-1';
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
deregisterOutgoingRequest: (id: string) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
(
dispatcher as unknown as {
deregisterOutgoingRequest: (id: string) => void;
}
).deregisterOutgoingRequest(requestId);
// Verify it was deregistered - response should not find it
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {},
},
};
dispatcher.handleControlResponse(response);
expect(resolve).not.toHaveBeenCalled();
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
});
it('should handle deregister of non-existent request gracefully', () => {
expect(() => {
(
dispatcher as unknown as {
deregisterOutgoingRequest: (id: string) => void;
}
).deregisterOutgoingRequest('non-existent');
}).not.toThrow();
});
});
});
});

View File

@@ -0,0 +1,353 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Control Dispatcher
*
* Layer 2 of the control plane architecture. Routes control requests between
* SDK and CLI to appropriate controllers, manages pending request registries,
* and handles cancellation/cleanup. Application code MUST NOT depend on
* controller instances exposed by this class; instead, use ControlService,
* which wraps these controllers with a stable programmatic API.
*
* Controllers:
* - SystemController: initialize, interrupt, set_model, supported_commands
* - PermissionController: can_use_tool, set_permission_mode
* - MCPController: mcp_message, mcp_server_status
* - HookController: hook_callback
*
* Note: Control request types are centrally defined in the ControlRequestType
* enum in packages/sdk/typescript/src/types/controlRequests.ts
*/
import type { IControlContext } from './ControlContext.js';
import type { IPendingRequestRegistry } from './controllers/baseController.js';
import { SystemController } from './controllers/systemController.js';
// import { PermissionController } from './controllers/permissionController.js';
// import { MCPController } from './controllers/mcpController.js';
// import { HookController } from './controllers/hookController.js';
import type {
CLIControlRequest,
CLIControlResponse,
ControlResponse,
ControlRequestPayload,
} from '../types.js';
/**
* Tracks an incoming request from SDK awaiting CLI response
*/
interface PendingIncomingRequest {
controller: string;
abortController: AbortController;
timeoutId: NodeJS.Timeout;
}
/**
* Tracks an outgoing request from CLI awaiting SDK response
*/
interface PendingOutgoingRequest {
controller: string;
resolve: (response: ControlResponse) => void;
reject: (error: Error) => void;
timeoutId: NodeJS.Timeout;
}
/**
* Central coordinator for control plane communication.
* Routes requests to controllers and manages request lifecycle.
*/
export class ControlDispatcher implements IPendingRequestRegistry {
private context: IControlContext;
// Make controllers publicly accessible
readonly systemController: SystemController;
// readonly permissionController: PermissionController;
// readonly mcpController: MCPController;
// readonly hookController: HookController;
// Central pending request registries
private pendingIncomingRequests: Map<string, PendingIncomingRequest> =
new Map();
private pendingOutgoingRequests: Map<string, PendingOutgoingRequest> =
new Map();
constructor(context: IControlContext) {
this.context = context;
// Create domain controllers with context and registry
this.systemController = new SystemController(
context,
this,
'SystemController',
);
// this.permissionController = new PermissionController(
// context,
// this,
// 'PermissionController',
// );
// this.mcpController = new MCPController(context, this, 'MCPController');
// this.hookController = new HookController(context, this, 'HookController');
// Listen for main abort signal
this.context.abortSignal.addEventListener('abort', () => {
this.shutdown();
});
}
/**
* Routes an incoming request to the appropriate controller and sends response
*/
async dispatch(request: CLIControlRequest): Promise<void> {
const { request_id, request: payload } = request;
try {
// Route to appropriate controller
const controller = this.getControllerForRequest(payload.subtype);
const response = await controller.handleRequest(payload, request_id);
// Send success response
this.sendSuccessResponse(request_id, response);
} catch (error) {
// Send error response
const errorMessage =
error instanceof Error ? error.message : String(error);
this.sendErrorResponse(request_id, errorMessage);
}
}
/**
* Processes response from SDK for an outgoing request
*/
handleControlResponse(response: CLIControlResponse): void {
const responsePayload = response.response;
const requestId = responsePayload.request_id;
const pending = this.pendingOutgoingRequests.get(requestId);
if (!pending) {
// No pending request found - may have timed out or been cancelled
if (this.context.debugMode) {
console.error(
`[ControlDispatcher] No pending outgoing request for: ${requestId}`,
);
}
return;
}
// Deregister
this.deregisterOutgoingRequest(requestId);
// Resolve or reject based on response type
if (responsePayload.subtype === 'success') {
pending.resolve(responsePayload);
} else {
const errorMessage =
typeof responsePayload.error === 'string'
? responsePayload.error
: (responsePayload.error?.message ?? 'Unknown error');
pending.reject(new Error(errorMessage));
}
}
/**
* Sends a control request to SDK and waits for response
*/
async sendControlRequest(
payload: ControlRequestPayload,
timeoutMs?: number,
): Promise<ControlResponse> {
// Delegate to system controller (or any controller, they all have the same method)
return this.systemController.sendControlRequest(payload, timeoutMs);
}
/**
* Cancels a specific request or all pending requests
*/
handleCancel(requestId?: string): void {
if (requestId) {
// Cancel specific incoming request
const pending = this.pendingIncomingRequests.get(requestId);
if (pending) {
pending.abortController.abort();
this.deregisterIncomingRequest(requestId);
this.sendErrorResponse(requestId, 'Request cancelled');
if (this.context.debugMode) {
console.error(
`[ControlDispatcher] Cancelled incoming request: ${requestId}`,
);
}
}
} else {
// Cancel ALL pending incoming requests
const requestIds = Array.from(this.pendingIncomingRequests.keys());
for (const id of requestIds) {
const pending = this.pendingIncomingRequests.get(id);
if (pending) {
pending.abortController.abort();
this.deregisterIncomingRequest(id);
this.sendErrorResponse(id, 'All requests cancelled');
}
}
if (this.context.debugMode) {
console.error(
`[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`,
);
}
}
}
/**
* Stops all pending requests and cleans up all controllers
*/
shutdown(): void {
if (this.context.debugMode) {
console.error('[ControlDispatcher] Shutting down');
}
// Cancel all incoming requests
for (const [
_requestId,
pending,
] of this.pendingIncomingRequests.entries()) {
pending.abortController.abort();
clearTimeout(pending.timeoutId);
}
this.pendingIncomingRequests.clear();
// Cancel all outgoing requests
for (const [
_requestId,
pending,
] of this.pendingOutgoingRequests.entries()) {
clearTimeout(pending.timeoutId);
pending.reject(new Error('Dispatcher shutdown'));
}
this.pendingOutgoingRequests.clear();
// Cleanup controllers (MCP controller will close all clients)
this.systemController.cleanup();
// this.permissionController.cleanup();
// this.mcpController.cleanup();
// this.hookController.cleanup();
}
/**
* Registers an incoming request in the pending registry
*/
registerIncomingRequest(
requestId: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
): void {
this.pendingIncomingRequests.set(requestId, {
controller,
abortController,
timeoutId,
});
}
/**
* Removes an incoming request from the pending registry
*/
deregisterIncomingRequest(requestId: string): void {
const pending = this.pendingIncomingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingIncomingRequests.delete(requestId);
}
}
/**
* Registers an outgoing request in the pending registry
*/
registerOutgoingRequest(
requestId: string,
controller: string,
resolve: (response: ControlResponse) => void,
reject: (error: Error) => void,
timeoutId: NodeJS.Timeout,
): void {
this.pendingOutgoingRequests.set(requestId, {
controller,
resolve,
reject,
timeoutId,
});
}
/**
* Removes an outgoing request from the pending registry
*/
deregisterOutgoingRequest(requestId: string): void {
const pending = this.pendingOutgoingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingOutgoingRequests.delete(requestId);
}
}
/**
* Returns the controller that handles the given request subtype
*/
private getControllerForRequest(subtype: string) {
switch (subtype) {
case 'initialize':
case 'interrupt':
case 'set_model':
case 'supported_commands':
return this.systemController;
// case 'can_use_tool':
// case 'set_permission_mode':
// return this.permissionController;
// case 'mcp_message':
// case 'mcp_server_status':
// return this.mcpController;
// case 'hook_callback':
// return this.hookController;
default:
throw new Error(`Unknown control request subtype: ${subtype}`);
}
}
/**
* Sends a success response back to SDK
*/
private sendSuccessResponse(
requestId: string,
response: Record<string, unknown>,
): void {
const controlResponse: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response,
},
};
this.context.streamJson.send(controlResponse);
}
/**
* Sends an error response back to SDK
*/
private sendErrorResponse(requestId: string, error: string): void {
const controlResponse: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error,
},
};
this.context.streamJson.send(controlResponse);
}
}

View File

@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Control Service - Public Programmatic API
*
* Provides type-safe access to control plane functionality for internal
* CLI code. This is the ONLY programmatic interface that should be used by:
* - nonInteractiveCli
* - Session managers
* - Tool execution handlers
* - Internal CLI logic
*
* DO NOT use ControlDispatcher or controllers directly from application code.
*
* Architecture:
* - ControlContext stores shared session state (Layer 1)
* - ControlDispatcher handles protocol-level routing (Layer 2)
* - ControlService provides programmatic API for internal CLI usage (Layer 3)
*
* ControlService and ControlDispatcher share controller instances to ensure
* a single source of truth. All higher level code MUST access the control
* plane exclusively through ControlService.
*/
import type { IControlContext } from './ControlContext.js';
import type { ControlDispatcher } from './ControlDispatcher.js';
import type {
// PermissionServiceAPI,
SystemServiceAPI,
// McpServiceAPI,
// HookServiceAPI,
} from './types/serviceAPIs.js';
/**
* Control Service
*
* Facade layer providing domain-grouped APIs for control plane operations.
* Shares controller instances with ControlDispatcher to ensure single source
* of truth and state consistency.
*/
export class ControlService {
private dispatcher: ControlDispatcher;
/**
* Construct ControlService
*
* @param context - Control context (unused directly, passed to dispatcher)
* @param dispatcher - Control dispatcher that owns the controller instances
*/
constructor(context: IControlContext, dispatcher: ControlDispatcher) {
this.dispatcher = dispatcher;
}
/**
* Permission Domain API
*
* Handles tool execution permissions, approval checks, and callbacks.
* Delegates to the shared PermissionController instance.
*/
// get permission(): PermissionServiceAPI {
// const controller = this.dispatcher.permissionController;
// return {
// /**
// * Check if a tool should be allowed based on current permission settings
// *
// * Evaluates permission mode and tool registry to determine if execution
// * should proceed. Can optionally modify tool arguments based on confirmation details.
// *
// * @param toolRequest - Tool call request information
// * @param confirmationDetails - Optional confirmation details for UI
// * @returns Permission decision with optional updated arguments
// */
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
//
// /**
// * Build UI suggestions for tool confirmation dialogs
// *
// * Creates actionable permission suggestions based on tool confirmation details.
// *
// * @param confirmationDetails - Tool confirmation details
// * @returns Array of permission suggestions or null
// */
// buildPermissionSuggestions:
// controller.buildPermissionSuggestions.bind(controller),
//
// /**
// * Get callback for monitoring tool call status updates
// *
// * Returns callback function for integration with CoreToolScheduler.
// *
// * @returns Callback function for tool call updates
// */
// getToolCallUpdateCallback:
// controller.getToolCallUpdateCallback.bind(controller),
// };
// }
/**
* System Domain API
*
* Handles system-level operations and session management.
* Delegates to the shared SystemController instance.
*/
get system(): SystemServiceAPI {
const controller = this.dispatcher.systemController;
return {
/**
* Get control capabilities
*
* Returns the control capabilities object indicating what control
* features are available. Used exclusively for the initialize
* control response. System messages do not include capabilities.
*
* @returns Control capabilities object
*/
getControlCapabilities: () => controller.buildControlCapabilities(),
};
}
/**
* MCP Domain API
*
* Handles Model Context Protocol server interactions.
* Delegates to the shared MCPController instance.
*/
// get mcp(): McpServiceAPI {
// return {
// /**
// * Get or create MCP client for a server (lazy initialization)
// *
// * Returns existing client or creates new connection.
// *
// * @param serverName - Name of the MCP server
// * @returns Promise with client and config
// */
// getMcpClient: async (serverName: string) => {
// // MCPController has a private method getOrCreateMcpClient
// // We need to expose it via the API
// // For now, throw error as placeholder
// // The actual implementation will be added when we update MCPController
// throw new Error(
// `getMcpClient not yet implemented in ControlService. Server: ${serverName}`,
// );
// },
//
// /**
// * List all available MCP servers
// *
// * Returns names of configured/connected MCP servers.
// *
// * @returns Array of server names
// */
// listServers: () => {
// // Get servers from context
// const sdkServers = Array.from(
// this.dispatcher.mcpController['context'].sdkMcpServers,
// );
// const cliServers = Array.from(
// this.dispatcher.mcpController['context'].mcpClients.keys(),
// );
// return [...new Set([...sdkServers, ...cliServers])];
// },
// };
// }
/**
* Hook Domain API
*
* Handles hook callback processing (placeholder for future expansion).
* Delegates to the shared HookController instance.
*/
// get hook(): HookServiceAPI {
// // HookController has no public methods yet - controller access reserved for future use
// return {};
// }
/**
* Cleanup all controllers
*
* Should be called on session shutdown. Delegates to dispatcher's shutdown
* method to ensure all controllers are properly cleaned up.
*/
cleanup(): void {
// Delegate to dispatcher which manages controller cleanup
this.dispatcher.shutdown();
}
}

View File

@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Base Controller
*
* Abstract base class for domain-specific control plane controllers.
* Provides common functionality for:
* - Handling incoming control requests (SDK -> CLI)
* - Sending outgoing control requests (CLI -> SDK)
* - Request lifecycle management with timeout and cancellation
* - Integration with central pending request registry
*/
import { randomUUID } from 'node:crypto';
import type { IControlContext } from '../ControlContext.js';
import type {
ControlRequestPayload,
ControlResponse,
CLIControlRequest,
} from '../../types.js';
const DEFAULT_REQUEST_TIMEOUT_MS = 30000; // 30 seconds
/**
* Registry interface for controllers to register/deregister pending requests
*/
export interface IPendingRequestRegistry {
registerIncomingRequest(
requestId: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
): void;
deregisterIncomingRequest(requestId: string): void;
registerOutgoingRequest(
requestId: string,
controller: string,
resolve: (response: ControlResponse) => void,
reject: (error: Error) => void,
timeoutId: NodeJS.Timeout,
): void;
deregisterOutgoingRequest(requestId: string): void;
}
/**
* Abstract base controller class
*
* Subclasses should implement handleRequestPayload() to process specific
* control request types.
*/
export abstract class BaseController {
protected context: IControlContext;
protected registry: IPendingRequestRegistry;
protected controllerName: string;
constructor(
context: IControlContext,
registry: IPendingRequestRegistry,
controllerName: string,
) {
this.context = context;
this.registry = registry;
this.controllerName = controllerName;
}
/**
* Handle an incoming control request
*
* Manages lifecycle: register -> process -> deregister
*/
async handleRequest(
payload: ControlRequestPayload,
requestId: string,
): Promise<Record<string, unknown>> {
const requestAbortController = new AbortController();
// Setup timeout
const timeoutId = setTimeout(() => {
requestAbortController.abort();
this.registry.deregisterIncomingRequest(requestId);
if (this.context.debugMode) {
console.error(`[${this.controllerName}] Request timeout: ${requestId}`);
}
}, DEFAULT_REQUEST_TIMEOUT_MS);
// Register with central registry
this.registry.registerIncomingRequest(
requestId,
this.controllerName,
requestAbortController,
timeoutId,
);
try {
const response = await this.handleRequestPayload(
payload,
requestAbortController.signal,
);
// Success - deregister
this.registry.deregisterIncomingRequest(requestId);
return response;
} catch (error) {
// Error - deregister
this.registry.deregisterIncomingRequest(requestId);
throw error;
}
}
/**
* Send an outgoing control request to SDK
*
* Manages lifecycle: register -> send -> wait for response -> deregister
*/
async sendControlRequest(
payload: ControlRequestPayload,
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
): Promise<ControlResponse> {
const requestId = randomUUID();
return new Promise<ControlResponse>((resolve, reject) => {
// Setup timeout
const timeoutId = setTimeout(() => {
this.registry.deregisterOutgoingRequest(requestId);
reject(new Error('Control request timeout'));
if (this.context.debugMode) {
console.error(
`[${this.controllerName}] Outgoing request timeout: ${requestId}`,
);
}
}, timeoutMs);
// Register with central registry
this.registry.registerOutgoingRequest(
requestId,
this.controllerName,
resolve,
reject,
timeoutId,
);
// Send control request
const request: CLIControlRequest = {
type: 'control_request',
request_id: requestId,
request: payload,
};
try {
this.context.streamJson.send(request);
} catch (error) {
this.registry.deregisterOutgoingRequest(requestId);
reject(error);
}
});
}
/**
* Abstract method: Handle specific request payload
*
* Subclasses must implement this to process their domain-specific requests.
*/
protected abstract handleRequestPayload(
payload: ControlRequestPayload,
signal: AbortSignal,
): Promise<Record<string, unknown>>;
/**
* Cleanup resources
*/
cleanup(): void {
// Subclasses can override to add cleanup logic
}
}

View File

@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Hook Controller
*
* Handles hook-related control requests:
* - hook_callback: Process hook callbacks (placeholder for future)
*/
import { BaseController } from './baseController.js';
import type {
ControlRequestPayload,
CLIHookCallbackRequest,
} from '../../types.js';
export class HookController extends BaseController {
/**
* Handle hook control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'hook_callback':
return this.handleHookCallback(payload as CLIHookCallbackRequest);
default:
throw new Error(`Unsupported request subtype in HookController`);
}
}
/**
* Handle hook_callback request
*
* Processes hook callbacks (placeholder implementation)
*/
private async handleHookCallback(
payload: CLIHookCallbackRequest,
): Promise<Record<string, unknown>> {
if (this.context.debugMode) {
console.error(`[HookController] Hook callback: ${payload.callback_id}`);
}
// Hook callback processing not yet implemented
return {
result: 'Hook callback processing not yet implemented',
callback_id: payload.callback_id,
tool_use_id: payload.tool_use_id,
};
}
}

View File

@@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* MCP Controller
*
* Handles MCP-related control requests:
* - mcp_message: Route MCP messages
* - mcp_server_status: Return MCP server status
*/
import { BaseController } from './baseController.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ResultSchema } from '@modelcontextprotocol/sdk/types.js';
import type {
ControlRequestPayload,
CLIControlMcpMessageRequest,
} from '../../types.js';
import type {
MCPServerConfig,
WorkspaceContext,
} from '@qwen-code/qwen-code-core';
import {
connectToMcpServer,
MCP_DEFAULT_TIMEOUT_MSEC,
} from '@qwen-code/qwen-code-core';
export class MCPController extends BaseController {
/**
* Handle MCP control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'mcp_message':
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
case 'mcp_server_status':
return this.handleMcpStatus();
default:
throw new Error(`Unsupported request subtype in MCPController`);
}
}
/**
* Handle mcp_message request
*
* Routes JSON-RPC messages to MCP servers
*/
private async handleMcpMessage(
payload: CLIControlMcpMessageRequest,
): Promise<Record<string, unknown>> {
const serverNameRaw = payload.server_name;
if (
typeof serverNameRaw !== 'string' ||
serverNameRaw.trim().length === 0
) {
throw new Error('Missing server_name in mcp_message request');
}
const message = payload.message;
if (!message || typeof message !== 'object') {
throw new Error(
'Missing or invalid message payload for mcp_message request',
);
}
// Get or create MCP client
let clientEntry: { client: Client; config: MCPServerConfig };
try {
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
} catch (error) {
throw new Error(
error instanceof Error
? error.message
: 'Failed to connect to MCP server',
);
}
const method = message.method;
if (typeof method !== 'string' || method.trim().length === 0) {
throw new Error('Invalid MCP message: missing method');
}
const jsonrpcVersion =
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
const messageId = message.id;
const params = message.params;
const timeout =
typeof clientEntry.config.timeout === 'number'
? clientEntry.config.timeout
: MCP_DEFAULT_TIMEOUT_MSEC;
try {
// Handle notification (no id)
if (messageId === undefined) {
await clientEntry.client.notification({
method,
params,
});
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: null,
result: { success: true, acknowledged: true },
},
};
}
// Handle request (with id)
const result = await clientEntry.client.request(
{
method,
params,
},
ResultSchema,
{ timeout },
);
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: messageId,
result,
},
};
} catch (error) {
// If connection closed, remove from cache
if (error instanceof Error && /closed/i.test(error.message)) {
this.context.mcpClients.delete(serverNameRaw.trim());
}
const errorCode =
typeof (error as { code?: unknown })?.code === 'number'
? ((error as { code: number }).code as number)
: -32603;
const errorMessage =
error instanceof Error
? error.message
: 'Failed to execute MCP request';
const errorData = (error as { data?: unknown })?.data;
const errorBody: Record<string, unknown> = {
code: errorCode,
message: errorMessage,
};
if (errorData !== undefined) {
errorBody['data'] = errorData;
}
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: messageId ?? null,
error: errorBody,
},
};
}
}
/**
* Handle mcp_server_status request
*
* Returns status of registered MCP servers
*/
private async handleMcpStatus(): Promise<Record<string, unknown>> {
const status: Record<string, string> = {};
// Include SDK MCP servers
for (const serverName of this.context.sdkMcpServers) {
status[serverName] = 'connected';
}
// Include CLI-managed MCP clients
for (const serverName of this.context.mcpClients.keys()) {
status[serverName] = 'connected';
}
if (this.context.debugMode) {
console.error(
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
);
}
return status;
}
/**
* Get or create MCP client for a server
*
* Implements lazy connection and caching
*/
private async getOrCreateMcpClient(
serverName: string,
): Promise<{ client: Client; config: MCPServerConfig }> {
// Check cache first
const cached = this.context.mcpClients.get(serverName);
if (cached) {
return cached;
}
// Get server configuration
const provider = this.context.config as unknown as {
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
getDebugMode?: () => boolean;
getWorkspaceContext?: () => unknown;
};
if (typeof provider.getMcpServers !== 'function') {
throw new Error(`MCP server "${serverName}" is not configured`);
}
const servers = provider.getMcpServers() ?? {};
const serverConfig = servers[serverName];
if (!serverConfig) {
throw new Error(`MCP server "${serverName}" is not configured`);
}
const debugMode =
typeof provider.getDebugMode === 'function'
? provider.getDebugMode()
: false;
const workspaceContext =
typeof provider.getWorkspaceContext === 'function'
? provider.getWorkspaceContext()
: undefined;
if (!workspaceContext) {
throw new Error('Workspace context is not available for MCP connection');
}
// Connect to MCP server
const client = await connectToMcpServer(
serverName,
serverConfig,
debugMode,
workspaceContext as WorkspaceContext,
);
// Cache the client
const entry = { client, config: serverConfig };
this.context.mcpClients.set(serverName, entry);
if (this.context.debugMode) {
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
}
return entry;
}
/**
* Cleanup MCP clients
*/
override cleanup(): void {
if (this.context.debugMode) {
console.error(
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
);
}
// Close all MCP clients
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
try {
client.close();
} catch (error) {
if (this.context.debugMode) {
console.error(
`[MCPController] Failed to close MCP client ${serverName}:`,
error,
);
}
}
}
this.context.mcpClients.clear();
}
}

View File

@@ -0,0 +1,483 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Permission Controller
*
* Handles permission-related control requests:
* - can_use_tool: Check if tool usage is allowed
* - set_permission_mode: Change permission mode at runtime
*
* Abstracts all permission logic from the session manager to keep it clean.
*/
import type {
ToolCallRequestInfo,
WaitingToolCall,
} from '@qwen-code/qwen-code-core';
import {
InputFormat,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
import type {
CLIControlPermissionRequest,
CLIControlSetPermissionModeRequest,
ControlRequestPayload,
PermissionMode,
PermissionSuggestion,
} from '../../types.js';
import { BaseController } from './baseController.js';
// Import ToolCallConfirmationDetails types for type alignment
type ToolConfirmationType = 'edit' | 'exec' | 'mcp' | 'info' | 'plan';
export class PermissionController extends BaseController {
private pendingOutgoingRequests = new Set<string>();
/**
* Handle permission control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'can_use_tool':
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
case 'set_permission_mode':
return this.handleSetPermissionMode(
payload as CLIControlSetPermissionModeRequest,
);
default:
throw new Error(`Unsupported request subtype in PermissionController`);
}
}
/**
* Handle can_use_tool request
*
* Comprehensive permission evaluation based on:
* - Permission mode (approval level)
* - Tool registry validation
* - Error handling with safe defaults
*/
private async handleCanUseTool(
payload: CLIControlPermissionRequest,
): Promise<Record<string, unknown>> {
const toolName = payload.tool_name;
if (
!toolName ||
typeof toolName !== 'string' ||
toolName.trim().length === 0
) {
return {
subtype: 'can_use_tool',
behavior: 'deny',
message: 'Missing or invalid tool_name in can_use_tool request',
};
}
let behavior: 'allow' | 'deny' = 'allow';
let message: string | undefined;
try {
// Check permission mode first
const permissionResult = this.checkPermissionMode();
if (!permissionResult.allowed) {
behavior = 'deny';
message = permissionResult.message;
}
// Check tool registry if permission mode allows
if (behavior === 'allow') {
const registryResult = this.checkToolRegistry(toolName);
if (!registryResult.allowed) {
behavior = 'deny';
message = registryResult.message;
}
}
} catch (error) {
behavior = 'deny';
message =
error instanceof Error
? `Failed to evaluate tool permission: ${error.message}`
: 'Failed to evaluate tool permission';
}
const response: Record<string, unknown> = {
subtype: 'can_use_tool',
behavior,
};
if (message) {
response['message'] = message;
}
return response;
}
/**
* Check permission mode for tool execution
*/
private checkPermissionMode(): { allowed: boolean; message?: string } {
const mode = this.context.permissionMode;
// Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES)
switch (mode) {
case 'yolo': // Allow all tools
case 'auto-edit': // Auto-approve edit operations
case 'plan': // Auto-approve planning operations
return { allowed: true };
case 'default': // TODO: allow all tools for test
default:
return {
allowed: false,
message:
'Tool execution requires manual approval. Update permission mode or approve via host.',
};
}
}
/**
* Check if tool exists in registry
*/
private checkToolRegistry(toolName: string): {
allowed: boolean;
message?: string;
} {
try {
// Access tool registry through config
const config = this.context.config;
const registryProvider = config as unknown as {
getToolRegistry?: () => {
getTool?: (name: string) => unknown;
};
};
if (typeof registryProvider.getToolRegistry === 'function') {
const registry = registryProvider.getToolRegistry();
if (
registry &&
typeof registry.getTool === 'function' &&
!registry.getTool(toolName)
) {
return {
allowed: false,
message: `Tool "${toolName}" is not registered.`,
};
}
}
return { allowed: true };
} catch (error) {
return {
allowed: false,
message: `Failed to check tool registry: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
}
/**
* Handle set_permission_mode request
*
* Updates the permission mode in the context
*/
private async handleSetPermissionMode(
payload: CLIControlSetPermissionModeRequest,
): Promise<Record<string, unknown>> {
const mode = payload.mode;
const validModes: PermissionMode[] = [
'default',
'plan',
'auto-edit',
'yolo',
];
if (!validModes.includes(mode)) {
throw new Error(
`Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`,
);
}
this.context.permissionMode = mode;
if (this.context.debugMode) {
console.error(
`[PermissionController] Permission mode updated to: ${mode}`,
);
}
return { status: 'updated', mode };
}
/**
* Build permission suggestions for tool confirmation UI
*
* This method creates UI suggestions based on tool confirmation details,
* helping the host application present appropriate permission options.
*/
buildPermissionSuggestions(
confirmationDetails: unknown,
): PermissionSuggestion[] | null {
if (
!confirmationDetails ||
typeof confirmationDetails !== 'object' ||
!('type' in confirmationDetails)
) {
return null;
}
const details = confirmationDetails as Record<string, unknown>;
const type = String(details['type'] ?? '');
const title =
typeof details['title'] === 'string' ? details['title'] : undefined;
// Ensure type matches ToolCallConfirmationDetails union
const confirmationType = type as ToolConfirmationType;
switch (confirmationType) {
case 'exec': // ToolExecuteConfirmationDetails
return [
{
type: 'allow',
label: 'Allow Command',
description: `Execute: ${details['command']}`,
},
{
type: 'deny',
label: 'Deny',
description: 'Block this command execution',
},
];
case 'edit': // ToolEditConfirmationDetails
return [
{
type: 'allow',
label: 'Allow Edit',
description: `Edit file: ${details['fileName']}`,
},
{
type: 'deny',
label: 'Deny',
description: 'Block this file edit',
},
{
type: 'modify',
label: 'Review Changes',
description: 'Review the proposed changes before applying',
},
];
case 'plan': // ToolPlanConfirmationDetails
return [
{
type: 'allow',
label: 'Approve Plan',
description: title || 'Execute the proposed plan',
},
{
type: 'deny',
label: 'Reject Plan',
description: 'Do not execute this plan',
},
];
case 'mcp': // ToolMcpConfirmationDetails
return [
{
type: 'allow',
label: 'Allow MCP Call',
description: `${details['serverName']}: ${details['toolName']}`,
},
{
type: 'deny',
label: 'Deny',
description: 'Block this MCP server call',
},
];
case 'info': // ToolInfoConfirmationDetails
return [
{
type: 'allow',
label: 'Allow Info Request',
description: title || 'Allow information request',
},
{
type: 'deny',
label: 'Deny',
description: 'Block this information request',
},
];
default:
// Fallback for unknown types
return [
{
type: 'allow',
label: 'Allow',
description: title || `Allow ${type} operation`,
},
{
type: 'deny',
label: 'Deny',
description: `Block ${type} operation`,
},
];
}
}
/**
* Check if a tool should be executed based on current permission settings
*
* This is a convenience method for direct tool execution checks without
* going through the control request flow.
*/
async shouldAllowTool(
toolRequest: ToolCallRequestInfo,
confirmationDetails?: unknown,
): Promise<{
allowed: boolean;
message?: string;
updatedArgs?: Record<string, unknown>;
}> {
// Check permission mode
const modeResult = this.checkPermissionMode();
if (!modeResult.allowed) {
return {
allowed: false,
message: modeResult.message,
};
}
// Check tool registry
const registryResult = this.checkToolRegistry(toolRequest.name);
if (!registryResult.allowed) {
return {
allowed: false,
message: registryResult.message,
};
}
// If we have confirmation details, we could potentially modify args
// This is a hook for future enhancement
if (confirmationDetails) {
// Future: handle argument modifications based on confirmation details
}
return { allowed: true };
}
/**
* Get callback for monitoring tool calls and handling outgoing permission requests
* This is passed to executeToolCall to hook into CoreToolScheduler updates
*/
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void {
return (toolCalls: unknown[]) => {
for (const call of toolCalls) {
if (
call &&
typeof call === 'object' &&
(call as { status?: string }).status === 'awaiting_approval'
) {
const awaiting = call as WaitingToolCall;
if (
typeof awaiting.confirmationDetails?.onConfirm === 'function' &&
!this.pendingOutgoingRequests.has(awaiting.request.callId)
) {
this.pendingOutgoingRequests.add(awaiting.request.callId);
void this.handleOutgoingPermissionRequest(awaiting);
}
}
}
};
}
/**
* Handle outgoing permission request
*
* Behavior depends on input format:
* - stream-json mode: Send can_use_tool to SDK and await response
* - Other modes: Check local approval mode and decide immediately
*/
private async handleOutgoingPermissionRequest(
toolCall: WaitingToolCall,
): Promise<void> {
try {
const inputFormat = this.context.config.getInputFormat?.();
const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON;
if (!isStreamJsonMode) {
// No SDK available - use local permission check
const modeCheck = this.checkPermissionMode();
const outcome = modeCheck.allowed
? ToolConfirmationOutcome.ProceedOnce
: ToolConfirmationOutcome.Cancel;
await toolCall.confirmationDetails.onConfirm(outcome);
return;
}
// Stream-json mode: ask SDK for permission
const permissionSuggestions = this.buildPermissionSuggestions(
toolCall.confirmationDetails,
);
const response = await this.sendControlRequest(
{
subtype: 'can_use_tool',
tool_name: toolCall.request.name,
tool_use_id: toolCall.request.callId,
input: toolCall.request.args,
permission_suggestions: permissionSuggestions,
blocked_path: null,
} as CLIControlPermissionRequest,
30000,
);
if (response.subtype !== 'success') {
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
return;
}
const payload = (response.response || {}) as Record<string, unknown>;
const behavior = String(payload['behavior'] || '').toLowerCase();
if (behavior === 'allow') {
// Handle updated input if provided
const updatedInput = payload['updatedInput'];
if (updatedInput && typeof updatedInput === 'object') {
toolCall.request.args = updatedInput as Record<string, unknown>;
}
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
} else {
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
}
} catch (error) {
if (this.context.debugMode) {
console.error(
'[PermissionController] Outgoing permission failed:',
error,
);
}
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
} finally {
this.pendingOutgoingRequests.delete(toolCall.request.callId);
}
}
}

View File

@@ -0,0 +1,215 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* System Controller
*
* Handles system-level control requests:
* - initialize: Setup session and return system info
* - interrupt: Cancel current operations
* - set_model: Switch model (placeholder)
*/
import { BaseController } from './baseController.js';
import type {
ControlRequestPayload,
CLIControlInitializeRequest,
CLIControlSetModelRequest,
} from '../../types.js';
export class SystemController extends BaseController {
/**
* Handle system control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'initialize':
return this.handleInitialize(payload as CLIControlInitializeRequest);
case 'interrupt':
return this.handleInterrupt();
case 'set_model':
return this.handleSetModel(payload as CLIControlSetModelRequest);
case 'supported_commands':
return this.handleSupportedCommands();
default:
throw new Error(`Unsupported request subtype in SystemController`);
}
}
/**
* Handle initialize request
*
* Registers SDK MCP servers and returns capabilities
*/
private async handleInitialize(
payload: CLIControlInitializeRequest,
): Promise<Record<string, unknown>> {
// Register SDK MCP servers if provided
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
for (const serverName of payload.sdkMcpServers) {
this.context.sdkMcpServers.add(serverName);
}
}
// Build capabilities for response
const capabilities = this.buildControlCapabilities();
if (this.context.debugMode) {
console.error(
`[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`,
);
}
return {
subtype: 'initialize',
capabilities,
};
}
/**
* Build control capabilities for initialize control response
*
* This method constructs the control capabilities object that indicates
* what control features are available. It is used exclusively in the
* initialize control response.
*/
buildControlCapabilities(): Record<string, unknown> {
const capabilities: Record<string, unknown> = {
can_handle_can_use_tool: true,
can_handle_hook_callback: true,
can_set_permission_mode:
typeof this.context.config.setApprovalMode === 'function',
can_set_model: typeof this.context.config.setModel === 'function',
};
// Check if MCP message handling is available
try {
const mcpProvider = this.context.config as unknown as {
getMcpServers?: () => Record<string, unknown> | undefined;
};
if (typeof mcpProvider.getMcpServers === 'function') {
const servers = mcpProvider.getMcpServers();
capabilities['can_handle_mcp_message'] = Boolean(
servers && Object.keys(servers).length > 0,
);
} else {
capabilities['can_handle_mcp_message'] = false;
}
} catch (error) {
if (this.context.debugMode) {
console.error(
'[SystemController] Failed to determine MCP capability:',
error,
);
}
capabilities['can_handle_mcp_message'] = false;
}
return capabilities;
}
/**
* Handle interrupt request
*
* Triggers the interrupt callback to cancel current operations
*/
private async handleInterrupt(): Promise<Record<string, unknown>> {
// Trigger interrupt callback if available
if (this.context.onInterrupt) {
this.context.onInterrupt();
}
// Abort the main signal to cancel ongoing operations
if (this.context.abortSignal && !this.context.abortSignal.aborted) {
// Note: We can't directly abort the signal, but the onInterrupt callback should handle this
if (this.context.debugMode) {
console.error('[SystemController] Interrupt signal triggered');
}
}
if (this.context.debugMode) {
console.error('[SystemController] Interrupt handled');
}
return { subtype: 'interrupt' };
}
/**
* Handle set_model request
*
* Implements actual model switching with validation and error handling
*/
private async handleSetModel(
payload: CLIControlSetModelRequest,
): Promise<Record<string, unknown>> {
const model = payload.model;
// Validate model parameter
if (typeof model !== 'string' || model.trim() === '') {
throw new Error('Invalid model specified for set_model request');
}
try {
// Attempt to set the model using config
await this.context.config.setModel(model);
if (this.context.debugMode) {
console.error(`[SystemController] Model switched to: ${model}`);
}
return {
subtype: 'set_model',
model,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to set model';
if (this.context.debugMode) {
console.error(
`[SystemController] Failed to set model ${model}:`,
error,
);
}
throw new Error(errorMessage);
}
}
/**
* Handle supported_commands request
*
* Returns list of supported control commands
*
* Note: This list should match the ControlRequestType enum in
* packages/sdk/typescript/src/types/controlRequests.ts
*/
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
const commands = [
'initialize',
'interrupt',
'set_model',
'supported_commands',
'can_use_tool',
'set_permission_mode',
'mcp_message',
'mcp_server_status',
'hook_callback',
];
return {
subtype: 'supported_commands',
commands,
};
}
}

View File

@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Service API Types
*
* These interfaces define the public API contract for the ControlService facade.
* They provide type-safe, domain-grouped access to control plane functionality
* for internal CLI code (nonInteractiveCli, session managers, etc.).
*/
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type {
ToolCallRequestInfo,
MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import type { PermissionSuggestion } from '../../types.js';
/**
* Permission Service API
*
* Provides permission-related operations including tool execution approval,
* permission suggestions, and tool call monitoring callbacks.
*/
export interface PermissionServiceAPI {
/**
* Check if a tool should be allowed based on current permission settings
*
* Evaluates permission mode and tool registry to determine if execution
* should proceed. Can optionally modify tool arguments based on confirmation details.
*
* @param toolRequest - Tool call request information containing name, args, and call ID
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
* @returns Promise resolving to permission decision with optional updated arguments
*/
shouldAllowTool(
toolRequest: ToolCallRequestInfo,
confirmationDetails?: unknown,
): Promise<{
allowed: boolean;
message?: string;
updatedArgs?: Record<string, unknown>;
}>;
/**
* Build UI suggestions for tool confirmation dialogs
*
* Creates actionable permission suggestions based on tool confirmation details,
* helping host applications present appropriate approval/denial options.
*
* @param confirmationDetails - Tool confirmation details (type, title, metadata)
* @returns Array of permission suggestions or null if details are invalid
*/
buildPermissionSuggestions(
confirmationDetails: unknown,
): PermissionSuggestion[] | null;
/**
* Get callback for monitoring tool call status updates
*
* Returns a callback function that should be passed to executeToolCall
* to enable integration with CoreToolScheduler updates. This callback
* handles outgoing permission requests for tools awaiting approval.
*
* @returns Callback function that processes tool call updates
*/
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void;
}
/**
* System Service API
*
* Provides system-level operations for the control system.
*
* Note: System messages and slash commands are NOT part of the control system API.
* They are handled independently via buildSystemMessage() from nonInteractiveHelpers.ts,
* regardless of whether the control system is available.
*/
export interface SystemServiceAPI {
/**
* Get control capabilities
*
* Returns the control capabilities object indicating what control
* features are available. Used exclusively for the initialize control
* response. System messages do not include capabilities as they are
* independent of the control system.
*
* @returns Control capabilities object
*/
getControlCapabilities(): Record<string, unknown>;
}
/**
* MCP Service API
*
* Provides Model Context Protocol server interaction including
* lazy client initialization and server discovery.
*/
export interface McpServiceAPI {
/**
* Get or create MCP client for a server (lazy initialization)
*
* Returns an existing client from cache or creates a new connection
* if this is the first request for the server. Handles connection
* lifecycle and error recovery.
*
* @param serverName - Name of the MCP server to connect to
* @returns Promise resolving to client instance and server configuration
* @throws Error if server is not configured or connection fails
*/
getMcpClient(serverName: string): Promise<{
client: Client;
config: MCPServerConfig;
}>;
/**
* List all available MCP servers
*
* Returns names of both SDK-managed and CLI-managed MCP servers
* that are currently configured or connected.
*
* @returns Array of server names
*/
listServers(): string[];
}
/**
* Hook Service API
*
* Provides hook callback processing (placeholder for future expansion).
*/
export interface HookServiceAPI {
// Future: Hook-related methods will be added here
// For now, hook functionality is handled only via control requests
registerHookCallback(callback: unknown): void;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,791 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type {
Config,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { GeminiEventType } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import { JsonOutputAdapter } from './JsonOutputAdapter.js';
function createMockConfig(): Config {
return {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getModel: vi.fn().mockReturnValue('test-model'),
} as unknown as Config;
}
describe('JsonOutputAdapter', () => {
let adapter: JsonOutputAdapter;
let mockConfig: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stdoutWriteSpy: any;
beforeEach(() => {
mockConfig = createMockConfig();
adapter = new JsonOutputAdapter(mockConfig);
stdoutWriteSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutWriteSpy.mockRestore();
});
describe('startAssistantMessage', () => {
it('should reset state for new message', () => {
adapter.startAssistantMessage();
adapter.startAssistantMessage(); // Start second message
// Should not throw
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
});
});
describe('processEvent', () => {
beforeEach(() => {
adapter.startAssistantMessage();
});
it('should append text content from Content events', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Content,
value: 'Hello',
};
adapter.processEvent(event);
const event2: ServerGeminiStreamEvent = {
type: GeminiEventType.Content,
value: ' World',
};
adapter.processEvent(event2);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'Hello World',
});
});
it('should append citation content from Citation events', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Citation,
value: 'Citation text',
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('Citation text'),
});
});
it('should ignore non-string citation values', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Citation,
value: 123,
} as unknown as ServerGeminiStreamEvent;
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(0);
});
it('should append thinking from Thought events', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking about the task',
},
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'thinking',
thinking: 'Planning: Thinking about the task',
signature: 'Planning',
});
});
it('should handle thinking with only subject', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: '',
},
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'thinking',
signature: 'Planning',
});
});
it('should append tool use from ToolCallRequest events', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'tool_use',
id: 'tool-call-1',
name: 'test_tool',
input: { param1: 'value1' },
});
});
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBe('tool_use');
});
it('should set stop_reason to null when message contains text blocks', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Some text',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBeNull();
});
it('should set stop_reason to null when message contains thinking blocks', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking about the task',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBeNull();
});
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool_1',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-2',
name: 'test_tool_2',
args: { param2: 'value2' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(2);
expect(
message.message.content.every((block) => block.type === 'tool_use'),
).toBe(true);
expect(message.message.stop_reason).toBe('tool_use');
});
it('should update usage from Finished event', () => {
const usageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 50,
cachedContentTokenCount: 10,
totalTokenCount: 160,
};
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata,
},
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.usage).toMatchObject({
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 10,
total_tokens: 160,
});
});
it('should finalize pending blocks on Finished event', () => {
// Add some text first
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Some text',
});
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: undefined },
};
adapter.processEvent(event);
// Should not throw when finalizing
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
});
it('should ignore events after finalization', () => {
adapter.finalizeAssistantMessage();
const originalContent =
adapter.finalizeAssistantMessage().message.content;
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Should be ignored',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toEqual(originalContent);
});
});
describe('finalizeAssistantMessage', () => {
beforeEach(() => {
adapter.startAssistantMessage();
});
it('should build and emit a complete assistant message', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test response',
});
const message = adapter.finalizeAssistantMessage();
expect(message.type).toBe('assistant');
expect(message.uuid).toBeTruthy();
expect(message.session_id).toBe('test-session-id');
expect(message.parent_tool_use_id).toBeNull();
expect(message.message.role).toBe('assistant');
expect(message.message.model).toBe('test-model');
expect(message.message.content).toHaveLength(1);
});
it('should return same message on subsequent calls', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test',
});
const message1 = adapter.finalizeAssistantMessage();
const message2 = adapter.finalizeAssistantMessage();
expect(message1).toEqual(message2);
});
it('should split different block types into separate assistant messages', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: { subject: 'Thinking', description: 'Thought' },
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0].type).toBe('thinking');
const storedMessages = (adapter as unknown as { messages: unknown[] })
.messages;
const assistantMessages = storedMessages.filter(
(
msg,
): msg is {
type: string;
message: { content: Array<{ type: string }> };
} => {
if (
typeof msg !== 'object' ||
msg === null ||
!('type' in msg) ||
(msg as { type?: string }).type !== 'assistant' ||
!('message' in msg)
) {
return false;
}
const message = (msg as { message?: unknown }).message;
return (
typeof message === 'object' &&
message !== null &&
'content' in message &&
Array.isArray((message as { content?: unknown }).content)
);
},
);
expect(assistantMessages).toHaveLength(2);
for (const assistant of assistantMessages) {
const uniqueTypes = new Set(
assistant.message.content.map((block) => block.type),
);
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
}
});
it('should throw if message not started', () => {
adapter = new JsonOutputAdapter(mockConfig);
expect(() => adapter.finalizeAssistantMessage()).toThrow(
'Message not started',
);
});
});
describe('emitResult', () => {
beforeEach(() => {
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Response text',
});
adapter.finalizeAssistantMessage();
});
it('should emit success result as JSON array', () => {
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage).toBeDefined();
expect(resultMessage.is_error).toBe(false);
expect(resultMessage.subtype).toBe('success');
expect(resultMessage.result).toBe('Response text');
expect(resultMessage.duration_ms).toBe(1000);
expect(resultMessage.num_turns).toBe(1);
});
it('should emit error result', () => {
adapter.emitResult({
isError: true,
errorMessage: 'Test error',
durationMs: 500,
apiDurationMs: 300,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage.is_error).toBe(true);
expect(resultMessage.subtype).toBe('error_during_execution');
expect(resultMessage.error?.message).toBe('Test error');
});
it('should use provided summary over extracted text', () => {
adapter.emitResult({
isError: false,
summary: 'Custom summary',
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage.result).toBe('Custom summary');
});
it('should include usage information', () => {
const usage = {
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
};
adapter.emitResult({
isError: false,
usage,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage.usage).toEqual(usage);
});
it('should include stats when provided', () => {
const stats = {
models: {},
tools: {
totalCalls: 5,
totalSuccess: 4,
totalFail: 1,
totalDurationMs: 1000,
totalDecisions: {
accept: 3,
reject: 1,
modify: 0,
auto_accept: 1,
},
byName: {},
},
files: {
totalLinesAdded: 10,
totalLinesRemoved: 5,
},
};
adapter.emitResult({
isError: false,
stats,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage.stats).toEqual(stats);
});
});
describe('emitUserMessage', () => {
it('should add user message to collection', () => {
const parts: Part[] = [{ text: 'Hello user' }];
adapter.emitUserMessage(parts);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const userMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'user',
);
expect(userMessage).toBeDefined();
expect(Array.isArray(userMessage.message.content)).toBe(true);
if (Array.isArray(userMessage.message.content)) {
expect(userMessage.message.content).toHaveLength(1);
expect(userMessage.message.content[0]).toEqual({
type: 'text',
text: 'Hello user',
});
}
});
it('should handle parent_tool_use_id', () => {
const parts: Part[] = [{ text: 'Tool response' }];
adapter.emitUserMessage(parts);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const userMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'user',
);
// emitUserMessage currently sets parent_tool_use_id to null
expect(userMessage.parent_tool_use_id).toBeNull();
});
});
describe('emitToolResult', () => {
it('should emit tool result message', () => {
const request = {
callId: 'tool-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const response = {
callId: 'tool-1',
responseParts: [],
resultDisplay: 'Tool executed successfully',
error: undefined,
errorType: undefined,
};
adapter.emitToolResult(request, response);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const toolResult = parsed.find(
(
msg: unknown,
): msg is { type: 'user'; message: { content: unknown[] } } =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'user' &&
'message' in msg &&
typeof msg.message === 'object' &&
msg.message !== null &&
'content' in msg.message &&
Array.isArray(msg.message.content) &&
msg.message.content[0] &&
typeof msg.message.content[0] === 'object' &&
'type' in msg.message.content[0] &&
msg.message.content[0].type === 'tool_result',
);
expect(toolResult).toBeDefined();
const block = toolResult.message.content[0] as {
type: 'tool_result';
tool_use_id: string;
content?: string;
is_error?: boolean;
};
expect(block).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'Tool executed successfully',
is_error: false,
});
});
it('should mark error tool results', () => {
const request = {
callId: 'tool-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const response = {
callId: 'tool-1',
responseParts: [],
resultDisplay: undefined,
error: new Error('Tool failed'),
errorType: undefined,
};
adapter.emitToolResult(request, response);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const toolResult = parsed.find(
(
msg: unknown,
): msg is { type: 'user'; message: { content: unknown[] } } =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'user' &&
'message' in msg &&
typeof msg.message === 'object' &&
msg.message !== null &&
'content' in msg.message &&
Array.isArray(msg.message.content),
);
const block = toolResult.message.content[0] as {
is_error?: boolean;
};
expect(block.is_error).toBe(true);
});
});
describe('emitSystemMessage', () => {
it('should add system message to collection', () => {
adapter.emitSystemMessage('test_subtype', { data: 'value' });
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const systemMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'system',
);
expect(systemMessage).toBeDefined();
expect(systemMessage.subtype).toBe('test_subtype');
expect(systemMessage.data).toEqual({ data: 'value' });
});
});
describe('getSessionId and getModel', () => {
it('should return session ID from config', () => {
expect(adapter.getSessionId()).toBe('test-session-id');
expect(mockConfig.getSessionId).toHaveBeenCalled();
});
it('should return model from config', () => {
expect(adapter.getModel()).toBe('test-model');
expect(mockConfig.getModel).toHaveBeenCalled();
});
});
describe('multiple messages in collection', () => {
it('should collect all messages and emit as array', () => {
adapter.emitSystemMessage('init', {});
adapter.emitUserMessage([{ text: 'User input' }]);
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Assistant response',
});
adapter.finalizeAssistantMessage();
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBeGreaterThanOrEqual(3);
const systemMsg = parsed[0] as { type?: string };
const userMsg = parsed[1] as { type?: string };
expect(systemMsg.type).toBe('system');
expect(userMsg.type).toBe('user');
expect(
parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
(msg as { type?: string }).type === 'assistant',
),
).toBeDefined();
expect(
parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
(msg as { type?: string }).type === 'result',
),
).toBeDefined();
});
});
});

View File

@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import type { CLIAssistantMessage, CLIMessage } from '../types.js';
import {
BaseJsonOutputAdapter,
type JsonOutputAdapterInterface,
type ResultOptions,
} from './BaseJsonOutputAdapter.js';
/**
* JSON output adapter that collects all messages and emits them
* as a single JSON array at the end of the turn.
* Supports both main agent and subagent messages through distinct APIs.
*/
export class JsonOutputAdapter
extends BaseJsonOutputAdapter
implements JsonOutputAdapterInterface
{
private readonly messages: CLIMessage[] = [];
constructor(config: Config) {
super(config);
}
/**
* Emits message to the messages array (batch mode).
* Tracks the last assistant message for efficient result text extraction.
*/
protected emitMessageImpl(message: CLIMessage): void {
this.messages.push(message);
// Track assistant messages for result generation
if (
typeof message === 'object' &&
message !== null &&
'type' in message &&
message.type === 'assistant'
) {
this.updateLastAssistantMessage(message as CLIAssistantMessage);
}
}
/**
* JSON mode does not emit stream events.
*/
protected shouldEmitStreamEvents(): boolean {
return false;
}
finalizeAssistantMessage(): CLIAssistantMessage {
const message = this.finalizeAssistantMessageInternal(
this.mainAgentMessageState,
null,
);
this.updateLastAssistantMessage(message);
return message;
}
emitResult(options: ResultOptions): void {
const resultMessage = this.buildResultMessage(
options,
this.lastAssistantMessage,
);
this.messages.push(resultMessage);
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
const json = JSON.stringify(this.messages);
process.stdout.write(`${json}\n`);
}
emitMessage(message: CLIMessage): void {
// In JSON mode, messages are collected in the messages array
// This is called by the base class's finalizeAssistantMessageInternal
// but can also be called directly for user/tool/system messages
this.messages.push(message);
}
}

View File

@@ -0,0 +1,215 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { PassThrough } from 'node:stream';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
StreamJsonInputReader,
StreamJsonParseError,
type StreamJsonInputMessage,
} from './StreamJsonInputReader.js';
describe('StreamJsonInputReader', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('read', () => {
/**
* Test parsing all supported message types in a single test
*/
it('should parse valid messages of all types', async () => {
const input = new PassThrough();
const reader = new StreamJsonInputReader(input);
const messages = [
{
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello world' }],
},
parent_tool_use_id: null,
},
{
type: 'control_request',
request_id: 'req-1',
request: { subtype: 'initialize' },
},
{
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-1',
response: { initialized: true },
},
},
{
type: 'control_cancel_request',
request_id: 'req-1',
},
];
for (const msg of messages) {
input.write(JSON.stringify(msg) + '\n');
}
input.end();
const parsed: StreamJsonInputMessage[] = [];
for await (const msg of reader.read()) {
parsed.push(msg);
}
expect(parsed).toHaveLength(messages.length);
expect(parsed).toEqual(messages);
});
it('should parse multiple messages', async () => {
const input = new PassThrough();
const reader = new StreamJsonInputReader(input);
const message1 = {
type: 'control_request',
request_id: 'req-1',
request: { subtype: 'initialize' },
};
const message2 = {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello' }],
},
parent_tool_use_id: null,
};
input.write(JSON.stringify(message1) + '\n');
input.write(JSON.stringify(message2) + '\n');
input.end();
const messages: StreamJsonInputMessage[] = [];
for await (const msg of reader.read()) {
messages.push(msg);
}
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(message1);
expect(messages[1]).toEqual(message2);
});
it('should skip empty lines and trim whitespace', async () => {
const input = new PassThrough();
const reader = new StreamJsonInputReader(input);
const message = {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello' }],
},
parent_tool_use_id: null,
};
input.write('\n');
input.write(' ' + JSON.stringify(message) + ' \n');
input.write(' \n');
input.write('\t\n');
input.end();
const messages: StreamJsonInputMessage[] = [];
for await (const msg of reader.read()) {
messages.push(msg);
}
expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(message);
});
/**
* Consolidated error handling test cases
*/
it.each([
{
name: 'invalid JSON',
input: '{"invalid": json}\n',
expectedError: 'Failed to parse stream-json line',
},
{
name: 'missing type field',
input:
JSON.stringify({ session_id: 'test-session', message: 'hello' }) +
'\n',
expectedError: 'Missing required "type" field',
},
{
name: 'non-object value (string)',
input: '"just a string"\n',
expectedError: 'Parsed value is not an object',
},
{
name: 'non-object value (null)',
input: 'null\n',
expectedError: 'Parsed value is not an object',
},
{
name: 'array value',
input: '[1, 2, 3]\n',
expectedError: 'Missing required "type" field',
},
{
name: 'type field not a string',
input: JSON.stringify({ type: 123, session_id: 'test-session' }) + '\n',
expectedError: 'Missing required "type" field',
},
])(
'should throw StreamJsonParseError for $name',
async ({ input: inputLine, expectedError }) => {
const input = new PassThrough();
const reader = new StreamJsonInputReader(input);
input.write(inputLine);
input.end();
const messages: StreamJsonInputMessage[] = [];
let error: unknown;
try {
for await (const msg of reader.read()) {
messages.push(msg);
}
} catch (e) {
error = e;
}
expect(messages).toHaveLength(0);
expect(error).toBeInstanceOf(StreamJsonParseError);
expect((error as StreamJsonParseError).message).toContain(
expectedError,
);
},
);
it('should use process.stdin as default input', () => {
const reader = new StreamJsonInputReader();
// Access private field for testing constructor default parameter
expect((reader as unknown as { input: typeof process.stdin }).input).toBe(
process.stdin,
);
});
it('should use provided input stream', () => {
const customInput = new PassThrough();
const reader = new StreamJsonInputReader(customInput);
// Access private field for testing constructor parameter
expect((reader as unknown as { input: typeof customInput }).input).toBe(
customInput,
);
});
});
});

View File

@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createInterface } from 'node:readline/promises';
import type { Readable } from 'node:stream';
import process from 'node:process';
import type {
CLIControlRequest,
CLIControlResponse,
CLIMessage,
ControlCancelRequest,
} from '../types.js';
export type StreamJsonInputMessage =
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest;
export class StreamJsonParseError extends Error {}
export class StreamJsonInputReader {
private readonly input: Readable;
constructor(input: Readable = process.stdin) {
this.input = input;
}
async *read(): AsyncGenerator<StreamJsonInputMessage> {
const rl = createInterface({
input: this.input,
crlfDelay: Number.POSITIVE_INFINITY,
terminal: false,
});
try {
for await (const rawLine of rl) {
const line = rawLine.trim();
if (!line) {
continue;
}
yield this.parse(line);
}
} finally {
rl.close();
}
}
private parse(line: string): StreamJsonInputMessage {
try {
const parsed = JSON.parse(line) as StreamJsonInputMessage;
if (!parsed || typeof parsed !== 'object') {
throw new StreamJsonParseError('Parsed value is not an object');
}
if (!('type' in parsed) || typeof parsed.type !== 'string') {
throw new StreamJsonParseError('Missing required "type" field');
}
return parsed;
} catch (error) {
if (error instanceof StreamJsonParseError) {
throw error;
}
const reason = error instanceof Error ? error.message : String(error);
throw new StreamJsonParseError(
`Failed to parse stream-json line: ${reason}`,
);
}
}
}

View File

@@ -0,0 +1,997 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type {
Config,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { GeminiEventType } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js';
function createMockConfig(): Config {
return {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getModel: vi.fn().mockReturnValue('test-model'),
} as unknown as Config;
}
describe('StreamJsonOutputAdapter', () => {
let adapter: StreamJsonOutputAdapter;
let mockConfig: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stdoutWriteSpy: any;
beforeEach(() => {
mockConfig = createMockConfig();
stdoutWriteSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutWriteSpy.mockRestore();
});
describe('with partial messages enabled', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
});
describe('startAssistantMessage', () => {
it('should reset state for new message', () => {
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'First',
});
adapter.finalizeAssistantMessage();
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Second',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'Second',
});
});
});
describe('processEvent with stream events', () => {
beforeEach(() => {
adapter.startAssistantMessage();
});
it('should emit stream events for text deltas', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Hello',
});
const calls = stdoutWriteSpy.mock.calls;
expect(calls.length).toBeGreaterThan(0);
const deltaEventCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta'
);
} catch {
return false;
}
});
expect(deltaEventCall).toBeDefined();
const parsed = JSON.parse(deltaEventCall![0] as string);
expect(parsed.event.type).toBe('content_block_delta');
expect(parsed.event.delta).toMatchObject({
type: 'text_delta',
text: 'Hello',
});
});
it('should emit message_start event on first content', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'First',
});
const calls = stdoutWriteSpy.mock.calls;
const messageStartCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'message_start'
);
} catch {
return false;
}
});
expect(messageStartCall).toBeDefined();
});
it('should emit content_block_start for new blocks', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
const calls = stdoutWriteSpy.mock.calls;
const blockStartCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_start'
);
} catch {
return false;
}
});
expect(blockStartCall).toBeDefined();
});
it('should emit thinking delta events', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking',
},
});
const calls = stdoutWriteSpy.mock.calls;
const deltaCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta' &&
parsed.event.delta.type === 'thinking_delta'
);
} catch {
return false;
}
});
expect(deltaCall).toBeDefined();
});
it('should emit message_stop on finalization', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
adapter.finalizeAssistantMessage();
const calls = stdoutWriteSpy.mock.calls;
const messageStopCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'message_stop'
);
} catch {
return false;
}
});
expect(messageStopCall).toBeDefined();
});
});
});
describe('with partial messages disabled', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should not emit stream events', () => {
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
const calls = stdoutWriteSpy.mock.calls;
const streamEventCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return parsed.type === 'stream_event';
} catch {
return false;
}
});
expect(streamEventCall).toBeUndefined();
});
it('should still emit final assistant message', () => {
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
adapter.finalizeAssistantMessage();
const calls = stdoutWriteSpy.mock.calls;
const assistantCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return parsed.type === 'assistant';
} catch {
return false;
}
});
expect(assistantCall).toBeDefined();
});
});
describe('processEvent', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
adapter.startAssistantMessage();
});
it('should append text content from Content events', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Hello',
});
adapter.processEvent({
type: GeminiEventType.Content,
value: ' World',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'Hello World',
});
});
it('should append citation content from Citation events', () => {
adapter.processEvent({
type: GeminiEventType.Citation,
value: 'Citation text',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('Citation text'),
});
});
it('should ignore non-string citation values', () => {
adapter.processEvent({
type: GeminiEventType.Citation,
value: 123,
} as unknown as ServerGeminiStreamEvent);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(0);
});
it('should append thinking from Thought events', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking about the task',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'thinking',
thinking: 'Planning: Thinking about the task',
signature: 'Planning',
});
});
it('should handle thinking with only subject', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: '',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'thinking',
signature: 'Planning',
});
});
it('should append tool use from ToolCallRequest events', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'tool_use',
id: 'tool-call-1',
name: 'test_tool',
input: { param1: 'value1' },
});
});
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBe('tool_use');
});
it('should set stop_reason to null when message contains text blocks', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Some text',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBeNull();
});
it('should set stop_reason to null when message contains thinking blocks', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking about the task',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBeNull();
});
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool_1',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-2',
name: 'test_tool_2',
args: { param2: 'value2' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(2);
expect(
message.message.content.every((block) => block.type === 'tool_use'),
).toBe(true);
expect(message.message.stop_reason).toBe('tool_use');
});
it('should update usage from Finished event', () => {
const usageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 50,
cachedContentTokenCount: 10,
totalTokenCount: 160,
};
adapter.processEvent({
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata,
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.usage).toMatchObject({
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 10,
total_tokens: 160,
});
});
it('should ignore events after finalization', () => {
adapter.finalizeAssistantMessage();
const originalContent =
adapter.finalizeAssistantMessage().message.content;
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Should be ignored',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toEqual(originalContent);
});
});
describe('finalizeAssistantMessage', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
adapter.startAssistantMessage();
});
it('should build and emit a complete assistant message', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test response',
});
const message = adapter.finalizeAssistantMessage();
expect(message.type).toBe('assistant');
expect(message.uuid).toBeTruthy();
expect(message.session_id).toBe('test-session-id');
expect(message.parent_tool_use_id).toBeNull();
expect(message.message.role).toBe('assistant');
expect(message.message.model).toBe('test-model');
expect(message.message.content).toHaveLength(1);
});
it('should emit message to stdout immediately', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test',
});
stdoutWriteSpy.mockClear();
adapter.finalizeAssistantMessage();
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('assistant');
});
it('should store message in lastAssistantMessage', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test',
});
const message = adapter.finalizeAssistantMessage();
// Access protected property for testing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((adapter as any).lastAssistantMessage).toEqual(message);
});
it('should return same message on subsequent calls', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test',
});
const message1 = adapter.finalizeAssistantMessage();
const message2 = adapter.finalizeAssistantMessage();
expect(message1).toEqual(message2);
});
it('should split different block types into separate assistant messages', () => {
stdoutWriteSpy.mockClear();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: { subject: 'Thinking', description: 'Thought' },
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0].type).toBe('thinking');
const assistantMessages = stdoutWriteSpy.mock.calls
.map((call: unknown[]) => JSON.parse(call[0] as string))
.filter(
(
payload: unknown,
): payload is {
type: string;
message: { content: Array<{ type: string }> };
} => {
if (
typeof payload !== 'object' ||
payload === null ||
!('type' in payload) ||
(payload as { type?: string }).type !== 'assistant' ||
!('message' in payload)
) {
return false;
}
const message = (payload as { message?: unknown }).message;
if (
typeof message !== 'object' ||
message === null ||
!('content' in message)
) {
return false;
}
const content = (message as { content?: unknown }).content;
return (
Array.isArray(content) &&
content.length > 0 &&
content.every(
(block: unknown) =>
typeof block === 'object' &&
block !== null &&
'type' in block,
)
);
},
);
expect(assistantMessages).toHaveLength(2);
const observedTypes = assistantMessages.map(
(payload: {
type: string;
message: { content: Array<{ type: string }> };
}) => payload.message.content[0]?.type ?? '',
);
expect(observedTypes).toEqual(['text', 'thinking']);
for (const payload of assistantMessages) {
const uniqueTypes = new Set(
payload.message.content.map((block: { type: string }) => block.type),
);
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
}
});
it('should throw if message not started', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
expect(() => adapter.finalizeAssistantMessage()).toThrow(
'Message not started',
);
});
});
describe('emitResult', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Response text',
});
adapter.finalizeAssistantMessage();
});
it('should emit success result immediately', () => {
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('result');
expect(parsed.is_error).toBe(false);
expect(parsed.subtype).toBe('success');
expect(parsed.result).toBe('Response text');
expect(parsed.duration_ms).toBe(1000);
expect(parsed.num_turns).toBe(1);
});
it('should emit error result', () => {
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: true,
errorMessage: 'Test error',
durationMs: 500,
apiDurationMs: 300,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.is_error).toBe(true);
expect(parsed.subtype).toBe('error_during_execution');
expect(parsed.error?.message).toBe('Test error');
});
it('should use provided summary over extracted text', () => {
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: false,
summary: 'Custom summary',
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.result).toBe('Custom summary');
});
it('should include usage information', () => {
const usage = {
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
};
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: false,
usage,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.usage).toEqual(usage);
});
it('should handle result without assistant message', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.result).toBe('');
});
});
describe('emitUserMessage', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should emit user message immediately', () => {
stdoutWriteSpy.mockClear();
const parts: Part[] = [{ text: 'Hello user' }];
adapter.emitUserMessage(parts);
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('user');
expect(Array.isArray(parsed.message.content)).toBe(true);
if (Array.isArray(parsed.message.content)) {
expect(parsed.message.content).toHaveLength(1);
expect(parsed.message.content[0]).toEqual({
type: 'text',
text: 'Hello user',
});
}
});
it('should handle parent_tool_use_id', () => {
const parts: Part[] = [{ text: 'Tool response' }];
adapter.emitUserMessage(parts);
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
// emitUserMessage currently sets parent_tool_use_id to null
expect(parsed.parent_tool_use_id).toBeNull();
});
});
describe('emitToolResult', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should emit tool result message immediately', () => {
stdoutWriteSpy.mockClear();
const request = {
callId: 'tool-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const response = {
callId: 'tool-1',
responseParts: [],
resultDisplay: 'Tool executed successfully',
error: undefined,
errorType: undefined,
};
adapter.emitToolResult(request, response);
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('user');
expect(parsed.parent_tool_use_id).toBeNull();
const block = parsed.message.content[0];
expect(block).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'Tool executed successfully',
is_error: false,
});
});
it('should mark error tool results', () => {
const request = {
callId: 'tool-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const response = {
callId: 'tool-1',
responseParts: [],
resultDisplay: undefined,
error: new Error('Tool failed'),
errorType: undefined,
};
adapter.emitToolResult(request, response);
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const block = parsed.message.content[0];
expect(block.is_error).toBe(true);
});
});
describe('emitSystemMessage', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should emit system message immediately', () => {
stdoutWriteSpy.mockClear();
adapter.emitSystemMessage('test_subtype', { data: 'value' });
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('system');
expect(parsed.subtype).toBe('test_subtype');
expect(parsed.data).toEqual({ data: 'value' });
});
});
describe('getSessionId and getModel', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should return session ID from config', () => {
expect(adapter.getSessionId()).toBe('test-session-id');
expect(mockConfig.getSessionId).toHaveBeenCalled();
});
it('should return model from config', () => {
expect(adapter.getModel()).toBe('test-model');
expect(mockConfig.getModel).toHaveBeenCalled();
});
});
describe('message_id in stream events', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
adapter.startAssistantMessage();
});
it('should include message_id in stream events after message starts', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
// Process another event to ensure messageStarted is true
adapter.processEvent({
type: GeminiEventType.Content,
value: 'More',
});
const calls = stdoutWriteSpy.mock.calls;
// Find all delta events
const deltaCalls = calls.filter((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta'
);
} catch {
return false;
}
});
expect(deltaCalls.length).toBeGreaterThan(0);
// The second delta event should have message_id (after messageStarted becomes true)
// message_id is added to the event object, so check parsed.event.message_id
if (deltaCalls.length > 1) {
const secondDelta = JSON.parse(
(deltaCalls[1] as unknown[])[0] as string,
);
// message_id is on the enriched event object
expect(
secondDelta.event.message_id || secondDelta.message_id,
).toBeTruthy();
} else {
// If only one delta, check if message_id exists
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
// message_id is added when messageStarted is true
// First event may or may not have it, but subsequent ones should
expect(delta.event.message_id || delta.message_id).toBeTruthy();
}
});
});
describe('multiple text blocks', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
adapter.startAssistantMessage();
});
it('should split assistant messages when block types change repeatedly', () => {
stdoutWriteSpy.mockClear();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text content',
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: { subject: 'Thinking', description: 'Thought' },
});
adapter.processEvent({
type: GeminiEventType.Content,
value: 'More text',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'More text',
});
const assistantMessages = stdoutWriteSpy.mock.calls
.map((call: unknown[]) => JSON.parse(call[0] as string))
.filter(
(
payload: unknown,
): payload is {
type: string;
message: { content: Array<{ type: string; text?: string }> };
} => {
if (
typeof payload !== 'object' ||
payload === null ||
!('type' in payload) ||
(payload as { type?: string }).type !== 'assistant' ||
!('message' in payload)
) {
return false;
}
const message = (payload as { message?: unknown }).message;
if (
typeof message !== 'object' ||
message === null ||
!('content' in message)
) {
return false;
}
const content = (message as { content?: unknown }).content;
return (
Array.isArray(content) &&
content.length > 0 &&
content.every(
(block: unknown) =>
typeof block === 'object' &&
block !== null &&
'type' in block,
)
);
},
);
expect(assistantMessages).toHaveLength(3);
const observedTypes = assistantMessages.map(
(msg: {
type: string;
message: { content: Array<{ type: string; text?: string }> };
}) => msg.message.content[0]?.type ?? '',
);
expect(observedTypes).toEqual(['text', 'thinking', 'text']);
for (const msg of assistantMessages) {
const uniqueTypes = new Set(
msg.message.content.map((block: { type: string }) => block.type),
);
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
}
});
it('should merge consecutive text fragments', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Hello',
});
adapter.processEvent({
type: GeminiEventType.Content,
value: ' ',
});
adapter.processEvent({
type: GeminiEventType.Content,
value: 'World',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'Hello World',
});
});
});
});

View File

@@ -0,0 +1,300 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { Config } from '@qwen-code/qwen-code-core';
import type {
CLIAssistantMessage,
CLIMessage,
CLIPartialAssistantMessage,
ControlMessage,
StreamEvent,
TextBlock,
ThinkingBlock,
ToolUseBlock,
} from '../types.js';
import {
BaseJsonOutputAdapter,
type MessageState,
type ResultOptions,
type JsonOutputAdapterInterface,
} from './BaseJsonOutputAdapter.js';
/**
* Stream JSON output adapter that emits messages immediately
* as they are completed during the streaming process.
* Supports both main agent and subagent messages through distinct APIs.
*/
export class StreamJsonOutputAdapter
extends BaseJsonOutputAdapter
implements JsonOutputAdapterInterface
{
constructor(
config: Config,
private readonly includePartialMessages: boolean,
) {
super(config);
}
/**
* Emits message immediately to stdout (stream mode).
*/
protected emitMessageImpl(message: CLIMessage | ControlMessage): void {
// Track assistant messages for result generation
if (
typeof message === 'object' &&
message !== null &&
'type' in message &&
message.type === 'assistant'
) {
this.updateLastAssistantMessage(message as CLIAssistantMessage);
}
// Emit messages immediately in stream mode
process.stdout.write(`${JSON.stringify(message)}\n`);
}
/**
* Stream mode emits stream events when includePartialMessages is enabled.
*/
protected shouldEmitStreamEvents(): boolean {
return this.includePartialMessages;
}
finalizeAssistantMessage(): CLIAssistantMessage {
const state = this.mainAgentMessageState;
if (state.finalized) {
return this.buildMessage(null);
}
state.finalized = true;
this.finalizePendingBlocks(state, null);
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
(a, b) => a - b,
);
for (const index of orderedOpenBlocks) {
this.onBlockClosed(state, index, null);
this.closeBlock(state, index);
}
if (state.messageStarted && this.includePartialMessages) {
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
}
const message = this.buildMessage(null);
this.updateLastAssistantMessage(message);
this.emitMessageImpl(message);
return message;
}
emitResult(options: ResultOptions): void {
const resultMessage = this.buildResultMessage(
options,
this.lastAssistantMessage,
);
this.emitMessageImpl(resultMessage);
}
emitMessage(message: CLIMessage | ControlMessage): void {
// In stream mode, emit immediately
this.emitMessageImpl(message);
}
send(message: CLIMessage | ControlMessage): void {
this.emitMessage(message);
}
/**
* Overrides base class hook to emit stream event when text block is created.
*/
protected override onTextBlockCreated(
state: MessageState,
index: number,
block: TextBlock,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_start',
index,
content_block: block,
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when text is appended.
*/
protected override onTextAppended(
state: MessageState,
index: number,
fragment: string,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text: fragment },
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when thinking block is created.
*/
protected override onThinkingBlockCreated(
state: MessageState,
index: number,
block: ThinkingBlock,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_start',
index,
content_block: block,
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when thinking is appended.
*/
protected override onThinkingAppended(
state: MessageState,
index: number,
fragment: string,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_delta',
index,
delta: { type: 'thinking_delta', thinking: fragment },
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when tool_use block is created.
*/
protected override onToolUseBlockCreated(
state: MessageState,
index: number,
block: ToolUseBlock,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_start',
index,
content_block: block,
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when tool_use input is set.
*/
protected override onToolUseInputSet(
state: MessageState,
index: number,
input: unknown,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_delta',
index,
delta: {
type: 'input_json_delta',
partial_json: JSON.stringify(input),
},
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when block is closed.
*/
protected override onBlockClosed(
state: MessageState,
index: number,
parentToolUseId: string | null,
): void {
if (this.includePartialMessages) {
this.emitStreamEventIfEnabled(
{
type: 'content_block_stop',
index,
},
parentToolUseId,
);
}
}
/**
* Overrides base class hook to emit message_start event when message is started.
* Only emits for main agent, not for subagents.
*/
protected override onEnsureMessageStarted(
state: MessageState,
parentToolUseId: string | null,
): void {
// Only emit message_start for main agent, not for subagents
if (parentToolUseId === null) {
this.emitStreamEventIfEnabled(
{
type: 'message_start',
message: {
id: state.messageId!,
role: 'assistant',
model: this.config.getModel(),
},
},
null,
);
}
}
/**
* Emits stream events when partial messages are enabled.
* This is a private method specific to StreamJsonOutputAdapter.
* @param event - Stream event to emit
* @param parentToolUseId - null for main agent, string for subagent
*/
private emitStreamEventIfEnabled(
event: StreamEvent,
parentToolUseId: string | null,
): void {
if (!this.includePartialMessages) {
return;
}
const state = this.getMessageState(parentToolUseId);
const enrichedEvent = state.messageStarted
? ({ ...event, message_id: state.messageId } as StreamEvent & {
message_id: string;
})
: event;
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: parentToolUseId,
event: enrichedEvent,
};
this.emitMessageImpl(partial);
}
}

View File

@@ -0,0 +1,591 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import { runNonInteractiveStreamJson } from './session.js';
import type {
CLIUserMessage,
CLIControlRequest,
CLIControlResponse,
ControlCancelRequest,
} from './types.js';
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
import { ControlDispatcher } from './control/ControlDispatcher.js';
import { ControlContext } from './control/ControlContext.js';
import { ControlService } from './control/ControlService.js';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
const runNonInteractiveMock = vi.fn();
// Mock dependencies
vi.mock('../nonInteractiveCli.js', () => ({
runNonInteractive: (...args: unknown[]) => runNonInteractiveMock(...args),
}));
vi.mock('./io/StreamJsonInputReader.js', () => ({
StreamJsonInputReader: vi.fn(),
}));
vi.mock('./io/StreamJsonOutputAdapter.js', () => ({
StreamJsonOutputAdapter: vi.fn(),
}));
vi.mock('./control/ControlDispatcher.js', () => ({
ControlDispatcher: vi.fn(),
}));
vi.mock('./control/ControlContext.js', () => ({
ControlContext: vi.fn(),
}));
vi.mock('./control/ControlService.js', () => ({
ControlService: vi.fn(),
}));
vi.mock('../ui/utils/ConsolePatcher.js', () => ({
ConsolePatcher: vi.fn(),
}));
interface ConfigOverrides {
getSessionId?: () => string;
getModel?: () => string;
getIncludePartialMessages?: () => boolean;
getDebugMode?: () => boolean;
getApprovalMode?: () => string;
getOutputFormat?: () => string;
[key: string]: unknown;
}
function createConfig(overrides: ConfigOverrides = {}): Config {
const base = {
getSessionId: () => 'test-session',
getModel: () => 'test-model',
getIncludePartialMessages: () => false,
getDebugMode: () => false,
getApprovalMode: () => 'auto',
getOutputFormat: () => 'stream-json',
};
return { ...base, ...overrides } as unknown as Config;
}
function createUserMessage(content: string): CLIUserMessage {
return {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content,
},
parent_tool_use_id: null,
};
}
function createControlRequest(
subtype: 'initialize' | 'set_model' | 'interrupt' = 'initialize',
): CLIControlRequest {
if (subtype === 'set_model') {
return {
type: 'control_request',
request_id: 'req-1',
request: {
subtype: 'set_model',
model: 'test-model',
},
};
}
if (subtype === 'interrupt') {
return {
type: 'control_request',
request_id: 'req-1',
request: {
subtype: 'interrupt',
},
};
}
return {
type: 'control_request',
request_id: 'req-1',
request: {
subtype: 'initialize',
},
};
}
function createControlResponse(requestId: string): CLIControlResponse {
return {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {},
},
};
}
function createControlCancel(requestId: string): ControlCancelRequest {
return {
type: 'control_cancel_request',
request_id: requestId,
};
}
describe('runNonInteractiveStreamJson', () => {
let config: Config;
let mockInputReader: {
read: () => AsyncGenerator<
| CLIUserMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest
>;
};
let mockOutputAdapter: {
emitResult: ReturnType<typeof vi.fn>;
};
let mockDispatcher: {
dispatch: ReturnType<typeof vi.fn>;
handleControlResponse: ReturnType<typeof vi.fn>;
handleCancel: ReturnType<typeof vi.fn>;
shutdown: ReturnType<typeof vi.fn>;
};
let mockConsolePatcher: {
patch: ReturnType<typeof vi.fn>;
cleanup: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
config = createConfig();
runNonInteractiveMock.mockReset();
// Setup mocks
mockConsolePatcher = {
patch: vi.fn(),
cleanup: vi.fn(),
};
(ConsolePatcher as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => mockConsolePatcher,
);
mockOutputAdapter = {
emitResult: vi.fn(),
} as {
emitResult: ReturnType<typeof vi.fn>;
[key: string]: unknown;
};
(
StreamJsonOutputAdapter as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockOutputAdapter);
mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
handleControlResponse: vi.fn(),
handleCancel: vi.fn(),
shutdown: vi.fn(),
};
(
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockDispatcher);
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({}),
);
(ControlService as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({}),
);
mockInputReader = {
async *read() {
// Default: empty stream
// Override in tests as needed
},
};
(
StreamJsonInputReader as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockInputReader);
runNonInteractiveMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('initializes session and processes initialize control request', async () => {
const initRequest = createControlRequest('initialize');
mockInputReader.read = async function* () {
yield initRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
});
it('processes user message when received as first message', async () => {
const userMessage = createUserMessage('Hello world');
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
const runCall = runNonInteractiveMock.mock.calls[0];
expect(runCall[2]).toBe('Hello world'); // Direct text, not processed
expect(typeof runCall[3]).toBe('string'); // promptId
expect(runCall[4]).toEqual(
expect.objectContaining({
abortController: expect.any(AbortController),
adapter: mockOutputAdapter,
}),
);
});
it('processes multiple user messages sequentially', async () => {
// Initialize first to enable multi-query mode
const initRequest = createControlRequest('initialize');
const userMessage1 = createUserMessage('First message');
const userMessage2 = createUserMessage('Second message');
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage1;
yield userMessage2;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
});
it('enqueues user messages received during processing', async () => {
const initRequest = createControlRequest('initialize');
const userMessage1 = createUserMessage('First message');
const userMessage2 = createUserMessage('Second message');
// Make runNonInteractive take some time to simulate processing
runNonInteractiveMock.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 10)),
);
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage1;
yield userMessage2;
};
await runNonInteractiveStreamJson(config, '');
// Both messages should be processed
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
});
it('processes control request in idle state', async () => {
const initRequest = createControlRequest('initialize');
const controlRequest = createControlRequest('set_model');
mockInputReader.read = async function* () {
yield initRequest;
yield controlRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(2);
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(1, initRequest);
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(2, controlRequest);
});
it('handles control response in idle state', async () => {
const initRequest = createControlRequest('initialize');
const controlResponse = createControlResponse('req-2');
mockInputReader.read = async function* () {
yield initRequest;
yield controlResponse;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
controlResponse,
);
});
it('handles control cancel in idle state', async () => {
const initRequest = createControlRequest('initialize');
const cancelRequest = createControlCancel('req-2');
mockInputReader.read = async function* () {
yield initRequest;
yield cancelRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleCancel).toHaveBeenCalledWith('req-2');
});
it('handles control request during processing state', async () => {
const initRequest = createControlRequest('initialize');
const userMessage = createUserMessage('Process me');
const controlRequest = createControlRequest('set_model');
runNonInteractiveMock.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 10)),
);
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage;
yield controlRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(controlRequest);
});
it('handles control response during processing state', async () => {
const initRequest = createControlRequest('initialize');
const userMessage = createUserMessage('Process me');
const controlResponse = createControlResponse('req-1');
runNonInteractiveMock.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 10)),
);
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage;
yield controlResponse;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
controlResponse,
);
});
it('handles user message with text content', async () => {
const userMessage = createUserMessage('Test message');
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
expect(runNonInteractiveMock).toHaveBeenCalledWith(
config,
expect.objectContaining({ merged: expect.any(Object) }),
'Test message',
expect.stringContaining('test-session'),
expect.objectContaining({
abortController: expect.any(AbortController),
adapter: mockOutputAdapter,
}),
);
});
it('handles user message with array content blocks', async () => {
const userMessage: CLIUserMessage = {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [
{ type: 'text', text: 'First part' },
{ type: 'text', text: 'Second part' },
],
},
parent_tool_use_id: null,
};
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
expect(runNonInteractiveMock).toHaveBeenCalledWith(
config,
expect.objectContaining({ merged: expect.any(Object) }),
'First part\nSecond part',
expect.stringContaining('test-session'),
expect.objectContaining({
abortController: expect.any(AbortController),
adapter: mockOutputAdapter,
}),
);
});
it('skips user message with no text content', async () => {
const userMessage: CLIUserMessage = {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [],
},
parent_tool_use_id: null,
};
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).not.toHaveBeenCalled();
});
it('handles error from processUserMessage', async () => {
const userMessage = createUserMessage('Test message');
const error = new Error('Processing error');
runNonInteractiveMock.mockRejectedValue(error);
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
// Error should be caught and handled gracefully
});
it('handles stream error gracefully', async () => {
const streamError = new Error('Stream error');
// eslint-disable-next-line require-yield
mockInputReader.read = async function* () {
throw streamError;
} as typeof mockInputReader.read;
await expect(runNonInteractiveStreamJson(config, '')).rejects.toThrow(
'Stream error',
);
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
});
it('stops processing when abort signal is triggered', async () => {
const initRequest = createControlRequest('initialize');
const userMessage = createUserMessage('Test message');
// Capture abort signal from ControlContext
let abortSignal: AbortSignal | null = null;
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(options: { abortSignal?: AbortSignal }) => {
abortSignal = options.abortSignal ?? null;
return {};
},
);
// Create input reader that aborts after first message
mockInputReader.read = async function* () {
yield initRequest;
// Abort the signal after initialization
if (abortSignal && !abortSignal.aborted) {
// The signal doesn't have an abort method, but the controller does
// Since we can't access the controller directly, we'll test by
// verifying that cleanup happens properly
}
// Yield second message - if abort works, it should be checked
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
// Verify initialization happened
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
expect(mockDispatcher.shutdown).toHaveBeenCalled();
});
it('generates unique prompt IDs for each message', async () => {
// Initialize first to enable multi-query mode
const initRequest = createControlRequest('initialize');
const userMessage1 = createUserMessage('First');
const userMessage2 = createUserMessage('Second');
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage1;
yield userMessage2;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
const promptId1 = runNonInteractiveMock.mock.calls[0][3] as string;
const promptId2 = runNonInteractiveMock.mock.calls[1][3] as string;
expect(promptId1).not.toBe(promptId2);
expect(promptId1).toContain('test-session');
expect(promptId2).toContain('test-session');
});
it('ignores non-initialize control request during initialization', async () => {
const controlRequest = createControlRequest('set_model');
mockInputReader.read = async function* () {
yield controlRequest;
};
await runNonInteractiveStreamJson(config, '');
// Should not transition to idle since it's not an initialize request
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
});
it('cleans up console patcher on completion', async () => {
mockInputReader.read = async function* () {
// Empty stream - should complete immediately
};
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
});
it('cleans up output adapter on completion', async () => {
mockInputReader.read = async function* () {
// Empty stream
};
await runNonInteractiveStreamJson(config, '');
});
it('calls dispatcher shutdown on completion', async () => {
const initRequest = createControlRequest('initialize');
mockInputReader.read = async function* () {
yield initRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.shutdown).toHaveBeenCalledTimes(1);
});
it('handles empty stream gracefully', async () => {
mockInputReader.read = async function* () {
// Empty stream
};
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,721 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Stream JSON Runner with Session State Machine
*
* Handles stream-json input/output format with:
* - Initialize handshake
* - Message routing (control vs user messages)
* - FIFO user message queue
* - Sequential message processing
* - Graceful shutdown
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
import { ControlContext } from './control/ControlContext.js';
import { ControlDispatcher } from './control/ControlDispatcher.js';
import { ControlService } from './control/ControlService.js';
import type {
CLIMessage,
CLIUserMessage,
CLIControlRequest,
CLIControlResponse,
ControlCancelRequest,
} from './types.js';
import {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
} from './types.js';
import { createMinimalSettings } from '../config/settings.js';
import { runNonInteractive } from '../nonInteractiveCli.js';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
const SESSION_STATE = {
INITIALIZING: 'initializing',
IDLE: 'idle',
PROCESSING_QUERY: 'processing_query',
SHUTTING_DOWN: 'shutting_down',
} as const;
type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE];
/**
* Message type classification for routing
*/
type MessageType =
| 'control_request'
| 'control_response'
| 'control_cancel'
| 'user'
| 'assistant'
| 'system'
| 'result'
| 'stream_event'
| 'unknown';
/**
* Routed message with classification
*/
interface RoutedMessage {
type: MessageType;
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest;
}
/**
* Session Manager
*
* Manages the session lifecycle and message processing state machine.
*/
class SessionManager {
private state: SessionState = SESSION_STATE.INITIALIZING;
private userMessageQueue: CLIUserMessage[] = [];
private abortController: AbortController;
private config: Config;
private sessionId: string;
private promptIdCounter: number = 0;
private inputReader: StreamJsonInputReader;
private outputAdapter: StreamJsonOutputAdapter;
private controlContext: ControlContext | null = null;
private dispatcher: ControlDispatcher | null = null;
private controlService: ControlService | null = null;
private controlSystemEnabled: boolean | null = null;
private debugMode: boolean;
private shutdownHandler: (() => void) | null = null;
private initialPrompt: CLIUserMessage | null = null;
constructor(config: Config, initialPrompt?: CLIUserMessage) {
this.config = config;
this.sessionId = config.getSessionId();
this.debugMode = config.getDebugMode();
this.abortController = new AbortController();
this.initialPrompt = initialPrompt ?? null;
this.inputReader = new StreamJsonInputReader();
this.outputAdapter = new StreamJsonOutputAdapter(
config,
config.getIncludePartialMessages(),
);
// Setup signal handlers for graceful shutdown
this.setupSignalHandlers();
}
/**
* Get next prompt ID
*/
private getNextPromptId(): string {
this.promptIdCounter++;
return `${this.sessionId}########${this.promptIdCounter}`;
}
/**
* Route a message to the appropriate handler based on its type
*
* Classifies incoming messages and routes them to appropriate handlers.
*/
private route(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): RoutedMessage {
// Check control messages first
if (isControlRequest(message)) {
return { type: 'control_request', message };
}
if (isControlResponse(message)) {
return { type: 'control_response', message };
}
if (isControlCancel(message)) {
return { type: 'control_cancel', message };
}
// Check data messages
if (isCLIUserMessage(message)) {
return { type: 'user', message };
}
if (isCLIAssistantMessage(message)) {
return { type: 'assistant', message };
}
if (isCLISystemMessage(message)) {
return { type: 'system', message };
}
if (isCLIResultMessage(message)) {
return { type: 'result', message };
}
if (isCLIPartialAssistantMessage(message)) {
return { type: 'stream_event', message };
}
// Unknown message type
if (this.debugMode) {
console.error(
'[SessionManager] Unknown message type:',
JSON.stringify(message, null, 2),
);
}
return { type: 'unknown', message };
}
/**
* Process a single message with unified logic for both initial prompt and stream messages.
*
* Handles:
* - Abort check
* - First message detection and handling
* - Normal message processing
* - Shutdown state checks
*
* @param message - Message to process
* @returns true if the calling code should exit (break/return), false to continue
*/
private async processSingleMessage(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): Promise<boolean> {
// Check for abort
if (this.abortController.signal.aborted) {
return true;
}
// Handle first message if control system not yet initialized
if (this.controlSystemEnabled === null) {
const handled = await this.handleFirstMessage(message);
if (handled) {
// If handled, check if we should shutdown
return this.state === SESSION_STATE.SHUTTING_DOWN;
}
// If not handled, fall through to normal processing
}
// Process message normally
await this.processMessage(message);
// Check for shutdown after processing
return this.state === SESSION_STATE.SHUTTING_DOWN;
}
/**
* Main entry point - run the session
*/
async run(): Promise<void> {
try {
if (this.debugMode) {
console.error('[SessionManager] Starting session', this.sessionId);
}
// Process initial prompt if provided
if (this.initialPrompt !== null) {
const shouldExit = await this.processSingleMessage(this.initialPrompt);
if (shouldExit) {
await this.shutdown();
return;
}
}
// Process messages from stream
for await (const message of this.inputReader.read()) {
const shouldExit = await this.processSingleMessage(message);
if (shouldExit) {
break;
}
}
// Stream closed, shutdown
await this.shutdown();
} catch (error) {
if (this.debugMode) {
console.error('[SessionManager] Error:', error);
}
await this.shutdown();
throw error;
} finally {
// Ensure signal handlers are always cleaned up even if shutdown wasn't called
this.cleanupSignalHandlers();
}
}
private ensureControlSystem(): void {
if (this.controlContext && this.dispatcher && this.controlService) {
return;
}
// The control system follows a strict three-layer architecture:
// 1. ControlContext (shared session state)
// 2. ControlDispatcher (protocol routing SDK ↔ CLI)
// 3. ControlService (programmatic API for CLI runtime)
//
// Application code MUST interact with the control plane exclusively through
// ControlService. ControlDispatcher is reserved for protocol-level message
// routing and should never be used directly outside of this file.
this.controlContext = new ControlContext({
config: this.config,
streamJson: this.outputAdapter,
sessionId: this.sessionId,
abortSignal: this.abortController.signal,
permissionMode: this.config.getApprovalMode(),
onInterrupt: () => this.handleInterrupt(),
});
this.dispatcher = new ControlDispatcher(this.controlContext);
this.controlService = new ControlService(
this.controlContext,
this.dispatcher,
);
}
private getDispatcher(): ControlDispatcher | null {
if (this.controlSystemEnabled !== true) {
return null;
}
if (!this.dispatcher) {
this.ensureControlSystem();
}
return this.dispatcher;
}
private async handleFirstMessage(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): Promise<boolean> {
const routed = this.route(message);
if (routed.type === 'control_request') {
const request = routed.message as CLIControlRequest;
this.controlSystemEnabled = true;
this.ensureControlSystem();
if (request.request.subtype === 'initialize') {
await this.dispatcher?.dispatch(request);
this.state = SESSION_STATE.IDLE;
return true;
}
return false;
}
if (routed.type === 'user') {
this.controlSystemEnabled = false;
this.state = SESSION_STATE.PROCESSING_QUERY;
this.userMessageQueue.push(routed.message as CLIUserMessage);
await this.processUserMessageQueue();
return true;
}
this.controlSystemEnabled = false;
return false;
}
/**
* Process a single message from the stream
*/
private async processMessage(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): Promise<void> {
const routed = this.route(message);
if (this.debugMode) {
console.error(
`[SessionManager] State: ${this.state}, Message type: ${routed.type}`,
);
}
switch (this.state) {
case SESSION_STATE.INITIALIZING:
await this.handleInitializingState(routed);
break;
case SESSION_STATE.IDLE:
await this.handleIdleState(routed);
break;
case SESSION_STATE.PROCESSING_QUERY:
await this.handleProcessingState(routed);
break;
case SESSION_STATE.SHUTTING_DOWN:
// Ignore all messages during shutdown
break;
default: {
// Exhaustive check
const _exhaustiveCheck: never = this.state;
if (this.debugMode) {
console.error('[SessionManager] Unknown state:', _exhaustiveCheck);
}
break;
}
}
}
/**
* Handle messages in initializing state
*/
private async handleInitializingState(routed: RoutedMessage): Promise<void> {
if (routed.type === 'control_request') {
const request = routed.message as CLIControlRequest;
const dispatcher = this.getDispatcher();
if (!dispatcher) {
if (this.debugMode) {
console.error(
'[SessionManager] Control request received before control system initialization',
);
}
return;
}
if (request.request.subtype === 'initialize') {
await dispatcher.dispatch(request);
this.state = SESSION_STATE.IDLE;
if (this.debugMode) {
console.error('[SessionManager] Initialized, transitioning to idle');
}
} else {
if (this.debugMode) {
console.error(
'[SessionManager] Ignoring non-initialize control request during initialization',
);
}
}
} else {
if (this.debugMode) {
console.error(
'[SessionManager] Ignoring non-control message during initialization',
);
}
}
}
/**
* Handle messages in idle state
*/
private async handleIdleState(routed: RoutedMessage): Promise<void> {
const dispatcher = this.getDispatcher();
if (routed.type === 'control_request') {
if (!dispatcher) {
if (this.debugMode) {
console.error('[SessionManager] Ignoring control request (disabled)');
}
return;
}
const request = routed.message as CLIControlRequest;
await dispatcher.dispatch(request);
// Stay in idle state
} else if (routed.type === 'control_response') {
if (!dispatcher) {
return;
}
const response = routed.message as CLIControlResponse;
dispatcher.handleControlResponse(response);
// Stay in idle state
} else if (routed.type === 'control_cancel') {
if (!dispatcher) {
return;
}
const cancelRequest = routed.message as ControlCancelRequest;
dispatcher.handleCancel(cancelRequest.request_id);
} else if (routed.type === 'user') {
const userMessage = routed.message as CLIUserMessage;
this.userMessageQueue.push(userMessage);
// Start processing queue
await this.processUserMessageQueue();
} else {
if (this.debugMode) {
console.error(
'[SessionManager] Ignoring message type in idle state:',
routed.type,
);
}
}
}
/**
* Handle messages in processing state
*/
private async handleProcessingState(routed: RoutedMessage): Promise<void> {
const dispatcher = this.getDispatcher();
if (routed.type === 'control_request') {
if (!dispatcher) {
if (this.debugMode) {
console.error(
'[SessionManager] Control request ignored during processing (disabled)',
);
}
return;
}
const request = routed.message as CLIControlRequest;
await dispatcher.dispatch(request);
// Continue processing
} else if (routed.type === 'control_response') {
if (!dispatcher) {
return;
}
const response = routed.message as CLIControlResponse;
dispatcher.handleControlResponse(response);
// Continue processing
} else if (routed.type === 'user') {
// Enqueue for later
const userMessage = routed.message as CLIUserMessage;
this.userMessageQueue.push(userMessage);
if (this.debugMode) {
console.error(
'[SessionManager] Enqueued user message during processing',
);
}
} else {
if (this.debugMode) {
console.error(
'[SessionManager] Ignoring message type during processing:',
routed.type,
);
}
}
}
/**
* Process user message queue (FIFO)
*/
private async processUserMessageQueue(): Promise<void> {
while (
this.userMessageQueue.length > 0 &&
!this.abortController.signal.aborted
) {
this.state = SESSION_STATE.PROCESSING_QUERY;
const userMessage = this.userMessageQueue.shift()!;
try {
await this.processUserMessage(userMessage);
} catch (error) {
if (this.debugMode) {
console.error(
'[SessionManager] Error processing user message:',
error,
);
}
// Send error result
this.emitErrorResult(error);
}
}
// If control system is disabled (single-query mode) and queue is empty,
// automatically shutdown instead of returning to idle
if (
!this.abortController.signal.aborted &&
this.state === SESSION_STATE.PROCESSING_QUERY &&
this.controlSystemEnabled === false &&
this.userMessageQueue.length === 0
) {
if (this.debugMode) {
console.error(
'[SessionManager] Single-query mode: queue processed, shutting down',
);
}
this.state = SESSION_STATE.SHUTTING_DOWN;
return;
}
// Return to idle after processing queue (for multi-query mode with control system)
if (
!this.abortController.signal.aborted &&
this.state === SESSION_STATE.PROCESSING_QUERY
) {
this.state = SESSION_STATE.IDLE;
if (this.debugMode) {
console.error('[SessionManager] Queue processed, returning to idle');
}
}
}
/**
* Process a single user message
*/
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
const input = extractUserMessageText(userMessage);
if (!input) {
if (this.debugMode) {
console.error('[SessionManager] No text content in user message');
}
return;
}
const promptId = this.getNextPromptId();
try {
await runNonInteractive(
this.config,
createMinimalSettings(),
input,
promptId,
{
abortController: this.abortController,
adapter: this.outputAdapter,
controlService: this.controlService ?? undefined,
},
);
} catch (error) {
// Error already handled by runNonInteractive via adapter.emitResult
if (this.debugMode) {
console.error('[SessionManager] Query execution error:', error);
}
}
}
/**
* Send tool results as user message
*/
private emitErrorResult(
error: unknown,
numTurns: number = 0,
durationMs: number = 0,
apiDurationMs: number = 0,
): void {
const message = error instanceof Error ? error.message : String(error);
this.outputAdapter.emitResult({
isError: true,
errorMessage: message,
durationMs,
apiDurationMs,
numTurns,
usage: undefined,
});
}
/**
* Handle interrupt control request
*/
private handleInterrupt(): void {
if (this.debugMode) {
console.error('[SessionManager] Interrupt requested');
}
// Abort current query if processing
if (this.state === SESSION_STATE.PROCESSING_QUERY) {
this.abortController.abort();
this.abortController = new AbortController(); // Create new controller for next query
}
}
/**
* Setup signal handlers for graceful shutdown
*/
private setupSignalHandlers(): void {
this.shutdownHandler = () => {
if (this.debugMode) {
console.error('[SessionManager] Shutdown signal received');
}
this.abortController.abort();
this.state = SESSION_STATE.SHUTTING_DOWN;
};
process.on('SIGINT', this.shutdownHandler);
process.on('SIGTERM', this.shutdownHandler);
}
/**
* Shutdown session and cleanup resources
*/
private async shutdown(): Promise<void> {
if (this.debugMode) {
console.error('[SessionManager] Shutting down');
}
this.state = SESSION_STATE.SHUTTING_DOWN;
this.dispatcher?.shutdown();
this.cleanupSignalHandlers();
}
/**
* Remove signal handlers to prevent memory leaks
*/
private cleanupSignalHandlers(): void {
if (this.shutdownHandler) {
process.removeListener('SIGINT', this.shutdownHandler);
process.removeListener('SIGTERM', this.shutdownHandler);
this.shutdownHandler = null;
}
}
}
function extractUserMessageText(message: CLIUserMessage): string | null {
const content = message.message.content;
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
const parts = content
.map((block) => {
if (!block || typeof block !== 'object') {
return '';
}
if ('type' in block && block.type === 'text' && 'text' in block) {
return typeof block.text === 'string' ? block.text : '';
}
return JSON.stringify(block);
})
.filter((part) => part.length > 0);
return parts.length > 0 ? parts.join('\n') : null;
}
return null;
}
/**
* Entry point for stream-json mode
*
* @param config - Configuration object
* @param input - Optional initial prompt input to process before reading from stream
*/
export async function runNonInteractiveStreamJson(
config: Config,
input: string,
): Promise<void> {
const consolePatcher = new ConsolePatcher({
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
try {
// Create initial user message from prompt input if provided
let initialPrompt: CLIUserMessage | undefined = undefined;
if (input && input.trim().length > 0) {
const sessionId = config.getSessionId();
initialPrompt = {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: input.trim(),
},
parent_tool_use_id: null,
};
}
const manager = new SessionManager(config, initialPrompt);
await manager.run();
} finally {
consolePatcher.cleanup();
}
}

View File

@@ -0,0 +1,509 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Annotation for attaching metadata to content blocks
*/
export interface Annotation {
type: string;
value: string;
}
/**
* Usage information types
*/
export interface Usage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
total_tokens?: number;
}
export interface ExtendedUsage extends Usage {
server_tool_use?: {
web_search_requests: number;
};
service_tier?: string;
cache_creation?: {
ephemeral_1h_input_tokens: number;
ephemeral_5m_input_tokens: number;
};
}
export interface ModelUsage {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
webSearchRequests: number;
contextWindow: number;
}
/**
* Permission denial information
*/
export interface CLIPermissionDenial {
tool_name: string;
tool_use_id: string;
tool_input: unknown;
}
/**
* Content block types from Anthropic SDK
*/
export interface TextBlock {
type: 'text';
text: string;
annotations?: Annotation[];
}
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
annotations?: Annotation[];
}
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
input: unknown;
annotations?: Annotation[];
}
export interface ToolResultBlock {
type: 'tool_result';
tool_use_id: string;
content?: string | ContentBlock[];
is_error?: boolean;
annotations?: Annotation[];
}
export type ContentBlock =
| TextBlock
| ThinkingBlock
| ToolUseBlock
| ToolResultBlock;
/**
* Anthropic SDK Message types
*/
export interface APIUserMessage {
role: 'user';
content: string | ContentBlock[];
}
export interface APIAssistantMessage {
id: string;
type: 'message';
role: 'assistant';
model: string;
content: ContentBlock[];
stop_reason?: string | null;
usage: Usage;
}
/**
* CLI Message wrapper types
*/
export interface CLIUserMessage {
type: 'user';
uuid?: string;
session_id: string;
message: APIUserMessage;
parent_tool_use_id: string | null;
options?: Record<string, unknown>;
}
export interface CLIAssistantMessage {
type: 'assistant';
uuid: string;
session_id: string;
message: APIAssistantMessage;
parent_tool_use_id: string | null;
}
export interface CLISystemMessage {
type: 'system';
subtype: string;
uuid: string;
session_id: string;
data?: unknown;
cwd?: string;
tools?: string[];
mcp_servers?: Array<{
name: string;
status: string;
}>;
model?: string;
permissionMode?: string;
slash_commands?: string[];
apiKeySource?: string;
qwen_code_version?: string;
output_style?: string;
agents?: string[];
skills?: string[];
capabilities?: Record<string, unknown>;
compact_metadata?: {
trigger: 'manual' | 'auto';
pre_tokens: number;
};
}
export interface CLIResultMessageSuccess {
type: 'result';
subtype: 'success';
uuid: string;
session_id: string;
is_error: false;
duration_ms: number;
duration_api_ms: number;
num_turns: number;
result: string;
usage: ExtendedUsage;
modelUsage?: Record<string, ModelUsage>;
permission_denials: CLIPermissionDenial[];
[key: string]: unknown;
}
export interface CLIResultMessageError {
type: 'result';
subtype: 'error_max_turns' | 'error_during_execution';
uuid: string;
session_id: string;
is_error: true;
duration_ms: number;
duration_api_ms: number;
num_turns: number;
usage: ExtendedUsage;
modelUsage?: Record<string, ModelUsage>;
permission_denials: CLIPermissionDenial[];
error?: {
type?: string;
message: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError;
/**
* Stream event types for real-time message updates
*/
export interface MessageStartStreamEvent {
type: 'message_start';
message: {
id: string;
role: 'assistant';
model: string;
};
}
export interface ContentBlockStartEvent {
type: 'content_block_start';
index: number;
content_block: ContentBlock;
}
export type ContentBlockDelta =
| {
type: 'text_delta';
text: string;
}
| {
type: 'thinking_delta';
thinking: string;
}
| {
type: 'input_json_delta';
partial_json: string;
};
export interface ContentBlockDeltaEvent {
type: 'content_block_delta';
index: number;
delta: ContentBlockDelta;
}
export interface ContentBlockStopEvent {
type: 'content_block_stop';
index: number;
}
export interface MessageStopStreamEvent {
type: 'message_stop';
}
export type StreamEvent =
| MessageStartStreamEvent
| ContentBlockStartEvent
| ContentBlockDeltaEvent
| ContentBlockStopEvent
| MessageStopStreamEvent;
export interface CLIPartialAssistantMessage {
type: 'stream_event';
uuid: string;
session_id: string;
event: StreamEvent;
parent_tool_use_id: string | null;
}
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo';
/**
* Permission suggestion for tool use requests
* TODO: Align with `ToolCallConfirmationDetails`
*/
export interface PermissionSuggestion {
type: 'allow' | 'deny' | 'modify';
label: string;
description?: string;
modifiedInput?: unknown;
}
/**
* Hook callback placeholder for future implementation
*/
export interface HookRegistration {
event: string;
callback_id: string;
}
/**
* Hook callback result placeholder for future implementation
*/
export interface HookCallbackResult {
shouldSkip?: boolean;
shouldInterrupt?: boolean;
suppressOutput?: boolean;
message?: string;
}
export interface CLIControlInterruptRequest {
subtype: 'interrupt';
}
export interface CLIControlPermissionRequest {
subtype: 'can_use_tool';
tool_name: string;
tool_use_id: string;
input: unknown;
permission_suggestions: PermissionSuggestion[] | null;
blocked_path: string | null;
}
export interface CLIControlInitializeRequest {
subtype: 'initialize';
hooks?: HookRegistration[] | null;
sdkMcpServers?: string[];
}
export interface CLIControlSetPermissionModeRequest {
subtype: 'set_permission_mode';
mode: PermissionMode;
}
export interface CLIHookCallbackRequest {
subtype: 'hook_callback';
callback_id: string;
input: unknown;
tool_use_id: string | null;
}
export interface CLIControlMcpMessageRequest {
subtype: 'mcp_message';
server_name: string;
message: {
jsonrpc?: string;
method: string;
params?: Record<string, unknown>;
id?: string | number | null;
};
}
export interface CLIControlSetModelRequest {
subtype: 'set_model';
model: string;
}
export interface CLIControlMcpStatusRequest {
subtype: 'mcp_server_status';
}
export interface CLIControlSupportedCommandsRequest {
subtype: 'supported_commands';
}
export type ControlRequestPayload =
| CLIControlInterruptRequest
| CLIControlPermissionRequest
| CLIControlInitializeRequest
| CLIControlSetPermissionModeRequest
| CLIHookCallbackRequest
| CLIControlMcpMessageRequest
| CLIControlSetModelRequest
| CLIControlMcpStatusRequest
| CLIControlSupportedCommandsRequest;
export interface CLIControlRequest {
type: 'control_request';
request_id: string;
request: ControlRequestPayload;
}
/**
* Permission approval result
*/
export interface PermissionApproval {
allowed: boolean;
reason?: string;
modifiedInput?: unknown;
}
export interface ControlResponse {
subtype: 'success';
request_id: string;
response: unknown;
}
export interface ControlErrorResponse {
subtype: 'error';
request_id: string;
error: string | { message: string; [key: string]: unknown };
}
export interface CLIControlResponse {
type: 'control_response';
response: ControlResponse | ControlErrorResponse;
}
export interface ControlCancelRequest {
type: 'control_cancel_request';
request_id?: string;
}
export type ControlMessage =
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest;
/**
* Union of all CLI message types
*/
export type CLIMessage =
| CLIUserMessage
| CLIAssistantMessage
| CLISystemMessage
| CLIResultMessage
| CLIPartialAssistantMessage;
/**
* Type guard functions for message discrimination
*/
export function isCLIUserMessage(msg: any): msg is CLIUserMessage {
return (
msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg
);
}
export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'assistant' &&
'uuid' in msg &&
'message' in msg &&
'session_id' in msg &&
'parent_tool_use_id' in msg
);
}
export function isCLISystemMessage(msg: any): msg is CLISystemMessage {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'system' &&
'subtype' in msg &&
'uuid' in msg &&
'session_id' in msg
);
}
export function isCLIResultMessage(msg: any): msg is CLIResultMessage {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'result' &&
'subtype' in msg &&
'duration_ms' in msg &&
'is_error' in msg &&
'uuid' in msg &&
'session_id' in msg
);
}
export function isCLIPartialAssistantMessage(
msg: any,
): msg is CLIPartialAssistantMessage {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'stream_event' &&
'uuid' in msg &&
'session_id' in msg &&
'event' in msg &&
'parent_tool_use_id' in msg
);
}
export function isControlRequest(msg: any): msg is CLIControlRequest {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'control_request' &&
'request_id' in msg &&
'request' in msg
);
}
export function isControlResponse(msg: any): msg is CLIControlResponse {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'control_response' &&
'response' in msg
);
}
export function isControlCancel(msg: any): msg is ControlCancelRequest {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'control_cancel_request' &&
'request_id' in msg
);
}
/**
* Content block type guards
*/
export function isTextBlock(block: any): block is TextBlock {
return block && typeof block === 'object' && block.type === 'text';
}
export function isThinkingBlock(block: any): block is ThinkingBlock {
return block && typeof block === 'object' && block.type === 'thinking';
}
export function isToolUseBlock(block: any): block is ToolUseBlock {
return block && typeof block === 'object' && block.type === 'tool_use';
}
export function isToolResultBlock(block: any): block is ToolResultBlock {
return block && typeof block === 'object' && block.type === 'tool_result';
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,16 @@ import {
FatalInputError,
promptIdContext,
OutputFormat,
JsonFormatter,
uiTelemetryService,
} from '@qwen-code/qwen-code-core';
import type { Content, Part } from '@google/genai';
import type { Content, Part, PartListUnion } from '@google/genai';
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js';
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
import type { ControlService } from './nonInteractive/control/ControlService.js';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
import {
handleError,
@@ -30,35 +32,93 @@ import {
handleCancellationError,
handleMaxTurnsExceededError,
} from './utils/errors.js';
import {
normalizePartList,
extractPartsFromUserMessage,
buildSystemMessage,
createTaskToolProgressHandler,
computeUsageFromMetrics,
} from './utils/nonInteractiveHelpers.js';
/**
* Provides optional overrides for `runNonInteractive` execution.
*
* @param abortController - Optional abort controller for cancellation.
* @param adapter - Optional JSON output adapter for structured output formats.
* @param userMessage - Optional CLI user message payload for preformatted input.
* @param controlService - Optional control service for future permission handling.
*/
export interface RunNonInteractiveOptions {
abortController?: AbortController;
adapter?: JsonOutputAdapterInterface;
userMessage?: CLIUserMessage;
controlService?: ControlService;
}
/**
* Executes the non-interactive CLI flow for a single request.
*/
export async function runNonInteractive(
config: Config,
settings: LoadedSettings,
input: string,
prompt_id: string,
options: RunNonInteractiveOptions = {},
): Promise<void> {
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: config.getDebugMode(),
});
// Create output adapter based on format
let adapter: JsonOutputAdapterInterface | undefined;
const outputFormat = config.getOutputFormat();
try {
consolePatcher.patch();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (options.adapter) {
adapter = options.adapter;
} else if (outputFormat === OutputFormat.JSON) {
adapter = new JsonOutputAdapter(config);
} else if (outputFormat === OutputFormat.STREAM_JSON) {
adapter = new StreamJsonOutputAdapter(
config,
config.getIncludePartialMessages(),
);
}
// Get readonly values once at the start
const sessionId = config.getSessionId();
const permissionMode = config.getApprovalMode() as PermissionMode;
let turnCount = 0;
let totalApiDurationMs = 0;
const startTime = Date.now();
const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// Exit gracefully if the pipe is closed.
process.stdout.removeListener('error', stdoutErrorHandler);
process.exit(0);
}
});
};
const geminiClient = config.getGeminiClient();
const abortController = options.abortController ?? new AbortController();
const abortController = new AbortController();
// Setup signal handlers for graceful shutdown
const shutdownHandler = () => {
if (config.getDebugMode()) {
console.error('[runNonInteractive] Shutdown signal received');
}
abortController.abort();
};
let query: Part[] | undefined;
try {
process.stdout.on('error', stdoutErrorHandler);
process.on('SIGINT', shutdownHandler);
process.on('SIGTERM', shutdownHandler);
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
options.userMessage,
);
if (!initialPartList) {
let slashHandled = false;
if (isSlashCommand(input)) {
const slashCommandResult = await handleSlashCommand(
input,
@@ -66,15 +126,14 @@ export async function runNonInteractive(
config,
settings,
);
// If a slash command is found and returns a prompt, use it.
// Otherwise, slashCommandResult fall through to the default prompt
// handling.
if (slashCommandResult) {
query = slashCommandResult as Part[];
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
initialPartList = slashCommandResult as PartListUnion;
slashHandled = true;
}
}
if (!query) {
if (!slashHandled) {
const { processedQuery, shouldProceed } = await handleAtCommand({
query: input,
config,
@@ -91,12 +150,26 @@ export async function runNonInteractive(
'Exiting due to an error processing the @ command.',
);
}
query = processedQuery as Part[];
initialPartList = processedQuery as PartListUnion;
}
}
let currentMessages: Content[] = [{ role: 'user', parts: query }];
if (!initialPartList) {
initialPartList = [{ text: input }];
}
const initialParts = normalizePartList(initialPartList);
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
let turnCount = 0;
while (true) {
turnCount++;
if (
@@ -105,43 +178,124 @@ export async function runNonInteractive(
) {
handleMaxTurnsExceededError(config);
}
const toolCallRequests: ToolCallRequestInfo[] = [];
const toolCallRequests: ToolCallRequestInfo[] = [];
const apiStartTime = Date.now();
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
);
let responseText = '';
// Start assistant message for this turn
if (adapter) {
adapter.startAssistantMessage();
}
for await (const event of responseStream) {
if (abortController.signal.aborted) {
handleCancellationError(config);
}
if (event.type === GeminiEventType.Content) {
if (config.getOutputFormat() === OutputFormat.JSON) {
responseText += event.value;
} else {
process.stdout.write(event.value);
if (adapter) {
// Use adapter for all event processing
adapter.processEvent(event);
if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
}
} else {
// Text output mode - direct stdout
if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
}
}
}
// Finalize assistant message
if (adapter) {
adapter.finalizeAssistantMessage();
}
totalApiDurationMs += Date.now() - apiStartTime;
if (toolCallRequests.length > 0) {
const toolResponseParts: Part[] = [];
for (const requestInfo of toolCallRequests) {
const finalRequestInfo = requestInfo;
/*
if (options.controlService) {
const permissionResult =
await options.controlService.permission.shouldAllowTool(
requestInfo,
);
if (!permissionResult.allowed) {
if (config.getDebugMode()) {
console.error(
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
permissionResult.message ?? '',
);
}
if (adapter && permissionResult.message) {
adapter.emitSystemMessage('tool_denied', {
tool: requestInfo.name,
message: permissionResult.message,
});
}
continue;
}
if (permissionResult.updatedArgs) {
finalRequestInfo = {
...requestInfo,
args: permissionResult.updatedArgs,
};
}
}
const toolCallUpdateCallback = options.controlService
? options.controlService.permission.getToolCallUpdateCallback()
: undefined;
*/
// Only pass outputUpdateHandler for Task tool
const isTaskTool = finalRequestInfo.name === 'task';
const taskToolProgress = isTaskTool
? createTaskToolProgressHandler(
config,
finalRequestInfo.callId,
adapter,
)
: undefined;
const taskToolProgressHandler = taskToolProgress?.handler;
const toolResponse = await executeToolCall(
config,
requestInfo,
finalRequestInfo,
abortController.signal,
isTaskTool && taskToolProgressHandler
? {
outputUpdateHandler: taskToolProgressHandler,
/*
toolCallUpdateCallback
? { onToolCallsUpdate: toolCallUpdateCallback }
: undefined,
*/
}
: undefined,
);
// Note: In JSON mode, subagent messages are automatically added to the main
// adapter's messages array and will be output together on emitResult()
if (toolResponse.error) {
// In JSON/STREAM_JSON mode, tool errors are tolerated and formatted
// as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode
// from config and allow the session to continue so the LLM can decide what to do next.
// In text mode, we still log the error.
handleToolError(
requestInfo.name,
finalRequestInfo.name,
toolResponse.error,
config,
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
@@ -149,6 +303,13 @@ export async function runNonInteractive(
? toolResponse.resultDisplay
: undefined,
);
// Note: We no longer emit a separate system message for tool errors
// in JSON/STREAM_JSON mode, as the error is already captured in the
// tool_result block with is_error=true.
}
if (adapter) {
adapter.emitToolResult(finalRequestInfo, toolResponse);
}
if (toolResponse.responseParts) {
@@ -157,20 +318,57 @@ export async function runNonInteractive(
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
process.stdout.write(formatter.format(responseText, stats));
// For JSON and STREAM_JSON modes, compute usage from metrics
if (adapter) {
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError: false,
durationMs: Date.now() - startTime,
apiDurationMs: totalApiDurationMs,
numTurns: turnCount,
usage,
stats,
});
} else {
process.stdout.write('\n'); // Ensure a final newline
// Text output mode - no usage needed
process.stdout.write('\n');
}
return;
}
}
} catch (error) {
// For JSON and STREAM_JSON modes, compute usage from metrics
const message = error instanceof Error ? error.message : String(error);
if (adapter) {
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError: true,
durationMs: Date.now() - startTime,
apiDurationMs: totalApiDurationMs,
numTurns: turnCount,
errorMessage: message,
usage,
stats,
});
}
handleError(error, config);
} finally {
consolePatcher.cleanup();
process.stdout.removeListener('error', stdoutErrorHandler);
// Cleanup signal handlers
process.removeListener('SIGINT', shutdownHandler);
process.removeListener('SIGTERM', shutdownHandler);
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry(config);
}

View File

@@ -13,15 +13,56 @@ import {
type Config,
} from '@qwen-code/qwen-code-core';
import { CommandService } from './services/CommandService.js';
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
import { FileCommandLoader } from './services/FileCommandLoader.js';
import type { CommandContext } from './ui/commands/types.js';
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './ui/commands/types.js';
import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js';
import type { LoadedSettings } from './config/settings.js';
import type { SessionStatsState } from './ui/contexts/SessionContext.js';
/**
* Filters commands based on the allowed built-in command names.
*
* - Always includes FILE commands
* - Only includes BUILT_IN commands if their name is in the allowed set
* - Excludes other command types (e.g., MCP_PROMPT) in non-interactive mode
*
* @param commands All loaded commands
* @param allowedBuiltinCommandNames Set of allowed built-in command names (empty = none allowed)
* @returns Filtered commands
*/
function filterCommandsForNonInteractive(
commands: readonly SlashCommand[],
allowedBuiltinCommandNames: Set<string>,
): SlashCommand[] {
return commands.filter((cmd) => {
if (cmd.kind === CommandKind.FILE) {
return true;
}
// Built-in commands: only include if in the allowed list
if (cmd.kind === CommandKind.BUILT_IN) {
return allowedBuiltinCommandNames.has(cmd.name);
}
// Exclude other types (e.g., MCP_PROMPT) in non-interactive mode
return false;
});
}
/**
* Processes a slash command in a non-interactive environment.
*
* @param rawQuery The raw query string (should start with '/')
* @param abortController Controller to cancel the operation
* @param config The configuration object
* @param settings The loaded settings
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. If not provided or empty, only file commands are available.
* @returns A Promise that resolves to `PartListUnion` if a valid command is
* found and results in a prompt, or `undefined` otherwise.
* @throws {FatalInputError} if the command result is not supported in
@@ -32,21 +73,35 @@ export const handleSlashCommand = async (
abortController: AbortController,
config: Config,
settings: LoadedSettings,
allowedBuiltinCommandNames?: string[],
): Promise<PartListUnion | undefined> => {
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/')) {
return;
}
// Only custom commands are supported for now.
const loaders = [new FileCommandLoader(config)];
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
: [new FileCommandLoader(config)];
const commandService = await CommandService.create(
loaders,
abortController.signal,
);
const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
commands,
allowedBuiltinSet,
);
const { commandToExecute, args } = parseSlashCommand(rawQuery, commands);
const { commandToExecute, args } = parseSlashCommand(
rawQuery,
filteredCommands,
);
if (commandToExecute) {
if (commandToExecute.action) {
@@ -107,3 +162,44 @@ export const handleSlashCommand = async (
return;
};
/**
* Retrieves all available slash commands for the current configuration.
*
* @param config The configuration object
* @param settings The loaded settings
* @param abortSignal Signal to cancel the loading process
* @param allowedBuiltinCommandNames Optional array of built-in command names that are
* allowed. If not provided or empty, only file commands are available.
* @returns A Promise that resolves to an array of SlashCommand objects
*/
export const getAvailableCommands = async (
config: Config,
settings: LoadedSettings,
abortSignal: AbortSignal,
allowedBuiltinCommandNames?: string[],
): Promise<SlashCommand[]> => {
try {
const allowedBuiltinSet = new Set(allowedBuiltinCommandNames ?? []);
// Only load BuiltinCommandLoader if there are allowed built-in commands
const loaders =
allowedBuiltinSet.size > 0
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
: [new FileCommandLoader(config)];
const commandService = await CommandService.create(loaders, abortSignal);
const commands = commandService.getCommands();
const filteredCommands = filterCommandsForNonInteractive(
commands,
allowedBuiltinSet,
);
// Filter out hidden commands
return filteredCommands.filter((cmd) => !cmd.hidden);
} catch (error) {
// Handle errors gracefully - log and return empty array
console.error('Error loading available commands:', error);
return [];
}
};

View File

@@ -24,6 +24,7 @@ import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
import { languageCommand } from '../ui/commands/languageCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
@@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
helpCommand,
await ideCommand(),
initCommand,
languageCommand,
mcpCommand,
memoryCommand,
modelCommand,

View File

@@ -1227,4 +1227,28 @@ describe('FileCommandLoader', () => {
expect(commands).toHaveLength(0);
});
});
describe('AbortError handling', () => {
it('should silently ignore AbortError when operation is cancelled', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test1.toml': 'prompt = "Prompt 1"',
'test2.toml': 'prompt = "Prompt 2"',
},
});
const loader = new FileCommandLoader(null);
const controller = new AbortController();
const signal = controller.signal;
// Start loading and immediately abort
const loadPromise = loader.loadCommands(signal);
controller.abort();
// Should not throw or print errors
const commands = await loadPromise;
expect(commands).toHaveLength(0);
});
});
});

View File

@@ -120,7 +120,11 @@ export class FileCommandLoader implements ICommandLoader {
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
// Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled)
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT';
const isAbortError =
error instanceof Error && error.name === 'AbortError';
if (!isEnoent && !isAbortError) {
console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,

View File

@@ -25,7 +25,6 @@ import {
type HistoryItem,
ToolCallStatus,
type HistoryItemWithoutId,
AuthState,
} from './types.js';
import { MessageType, StreamingState } from './types.js';
import {
@@ -48,11 +47,11 @@ import { useHistory } from './hooks/useHistoryManager.js';
import { useMemoryMonitor } from './hooks/useMemoryMonitor.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js';
import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
@@ -90,12 +89,15 @@ import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js';
import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -335,25 +337,24 @@ export const AppContainer = (props: AppContainerProps) => {
initializationResult.themeError,
);
const {
isApprovalModeDialogOpen,
openApprovalModeDialog,
handleApprovalModeSelect,
} = useApprovalModeCommand(settings, config);
const {
setAuthState,
authError,
onAuthError,
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect,
openAuthDialog,
} = useAuthCommand(settings, config);
// Qwen OAuth authentication state
const {
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
cancelQwenAuth,
} = useQwenAuth(settings, isAuthenticating);
cancelAuthentication,
} = useAuthCommand(settings, config, historyManager.addItem);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config,
@@ -363,19 +364,7 @@ export const AppContainer = (props: AppContainerProps) => {
setModelSwitchedFromQuotaError,
});
// Handle Qwen OAuth timeout
const handleQwenAuthTimeout = useCallback(() => {
onAuthError('Qwen OAuth authentication timed out. Please try again.');
cancelQwenAuth();
setAuthState(AuthState.Updating);
}, [onAuthError, cancelQwenAuth, setAuthState]);
// Handle Qwen OAuth cancel
const handleQwenAuthCancel = useCallback(() => {
onAuthError('Qwen OAuth authentication cancelled.');
cancelQwenAuth();
setAuthState(AuthState.Updating);
}, [onAuthError, cancelQwenAuth, setAuthState]);
useInitializationAuthError(initializationResult.authError, onAuthError);
// Sync user tier from config when authentication changes
// TODO: Implement getUserTier() method on Config if needed
@@ -387,6 +376,8 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch
useEffect(() => {
// Check for initialization error first
if (
settings.merged.security?.auth?.enforcedType &&
settings.merged.security?.auth.selectedType &&
@@ -394,7 +385,13 @@ export const AppContainer = (props: AppContainerProps) => {
settings.merged.security?.auth.selectedType
) {
onAuthError(
`Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
t(
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
{
enforcedType: settings.merged.security?.auth.enforcedType,
currentType: settings.merged.security?.auth.selectedType,
},
),
);
} else if (
settings.merged.security?.auth?.selectedType &&
@@ -470,6 +467,7 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog,
openModelDialog,
openPermissionsDialog,
openApprovalModeDialog,
quit: (messages: HistoryItem[]) => {
setQuittingMessages(messages);
setTimeout(async () => {
@@ -495,6 +493,7 @@ export const AppContainer = (props: AppContainerProps) => {
setCorgiMode,
dispatchExtensionStateUpdate,
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
showQuitConfirmation,
openSubagentCreateDialog,
@@ -551,6 +550,11 @@ export const AppContainer = (props: AppContainerProps) => {
[visionSwitchResolver],
);
// onDebugMessage should log to console, not update footer debugMessage
const onDebugMessage = useCallback((message: string) => {
console.debug(message);
}, []);
const performMemoryRefresh = useCallback(async () => {
historyManager.addItem(
{
@@ -628,7 +632,7 @@ export const AppContainer = (props: AppContainerProps) => {
historyManager.addItem,
config,
settings,
setDebugMessage,
onDebugMessage,
handleSlashCommand,
shellModeActive,
() => settings.merged.general?.preferredEditor as EditorType,
@@ -930,13 +934,21 @@ export const AppContainer = (props: AppContainerProps) => {
settings.merged.ui?.customWittyPhrases,
);
useAttentionNotifications({
isFocused,
streamingState,
elapsedTime,
});
// Dialog close functionality
const { closeAnyOpenDialog } = useDialogClose({
isThemeDialogOpen,
handleThemeSelect,
isApprovalModeDialogOpen,
handleApprovalModeSelect,
isAuthDialogOpen,
handleAuthSelect,
selectedAuthType: settings.merged.security?.auth?.selectedType,
pendingAuthType,
isEditorDialogOpen,
exitEditorDialog,
isSettingsDialogOpen,
@@ -1178,12 +1190,13 @@ export const AppContainer = (props: AppContainerProps) => {
isVisionSwitchDialogOpen ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
(isAuthenticating && isQwenAuthenticating) ||
isAuthenticating ||
isEditorDialogOpen ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen;
isAgentsManagerDialogOpen ||
isApprovalModeDialogOpen;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -1200,12 +1213,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized,
authError,
isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
@@ -1214,6 +1224,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1294,12 +1305,9 @@ export const AppContainer = (props: AppContainerProps) => {
isConfigInitialized,
authError,
isAuthDialogOpen,
pendingAuthType,
// Qwen OAuth state
isQwenAuth,
isQwenAuthenticating,
deviceAuth,
authStatus,
authMessage,
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
@@ -1308,6 +1316,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSettingsDialogOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
slashCommands,
pendingSlashCommandHistoryItems,
commandContext,
@@ -1388,12 +1397,11 @@ export const AppContainer = (props: AppContainerProps) => {
() => ({
handleThemeSelect,
handleThemeHighlight,
handleApprovalModeSelect,
handleAuthSelect,
setAuthState,
onAuthError,
// Qwen OAuth handlers
handleQwenAuthTimeout,
handleQwenAuthCancel,
cancelAuthentication,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,
@@ -1423,12 +1431,11 @@ export const AppContainer = (props: AppContainerProps) => {
[
handleThemeSelect,
handleThemeHighlight,
handleApprovalModeSelect,
handleAuthSelect,
setAuthState,
onAuthError,
// Qwen OAuth handlers
handleQwenAuthTimeout,
handleQwenAuthCancel,
cancelAuthentication,
handleEditorSelect,
exitEditorDialog,
closeSettingsDialog,

View File

@@ -9,6 +9,53 @@ import { AuthDialog } from './AuthDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { UIActionsContext } from '../contexts/UIActionsContext.js';
import type { UIState } from '../contexts/UIStateContext.js';
import type { UIActions } from '../contexts/UIActionsContext.js';
const createMockUIState = (overrides: Partial<UIState> = {}): UIState => {
// AuthDialog only uses authError and pendingAuthType
const baseState = {
authError: null,
pendingAuthType: undefined,
} as Partial<UIState>;
return {
...baseState,
...overrides,
} as UIState;
};
const createMockUIActions = (overrides: Partial<UIActions> = {}): UIActions => {
// AuthDialog only uses handleAuthSelect
const baseActions = {
handleAuthSelect: vi.fn(),
} as Partial<UIActions>;
return {
...baseActions,
...overrides,
} as UIActions;
};
const renderAuthDialog = (
settings: LoadedSettings,
uiStateOverrides: Partial<UIState> = {},
uiActionsOverrides: Partial<UIActions> = {},
) => {
const uiState = createMockUIState(uiStateOverrides);
const uiActions = createMockUIActions(uiActionsOverrides);
return renderWithProviders(
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<AuthDialog />
</UIActionsContext.Provider>
</UIStateContext.Provider>,
{ settings },
);
};
describe('AuthDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -66,13 +113,9 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog
onSelect={() => {}}
settings={settings}
initialErrorMessage="GEMINI_API_KEY environment variable not found"
/>,
);
const { lastFrame } = renderAuthDialog(settings, {
authError: 'GEMINI_API_KEY environment variable not found',
});
expect(lastFrame()).toContain(
'GEMINI_API_KEY environment variable not found',
@@ -116,9 +159,7 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
@@ -162,9 +203,7 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
const { lastFrame } = renderAuthDialog(settings);
expect(lastFrame()).not.toContain(
'Existing API key detected (GEMINI_API_KEY)',
@@ -208,9 +247,7 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog only shows OpenAI option now,
// it won't show GEMINI_API_KEY messages
@@ -255,9 +292,7 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
const { lastFrame } = renderAuthDialog(settings);
// This is a bit brittle, but it's the best way to check which item is selected.
expect(lastFrame()).toContain('● 2. OpenAI');
@@ -297,9 +332,7 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
const { lastFrame } = renderAuthDialog(settings);
// Default is Qwen OAuth (first option)
expect(lastFrame()).toContain('● 1. Qwen OAuth');
@@ -341,9 +374,7 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
const { lastFrame } = renderAuthDialog(settings);
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option
@@ -352,7 +383,7 @@ describe('AuthDialog', () => {
});
it('should prevent exiting when no auth method is selected and show error message', async () => {
const onSelect = vi.fn();
const handleAuthSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -386,8 +417,10 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
const { lastFrame, stdin, unmount } = renderAuthDialog(
settings,
{},
{ handleAuthSelect },
);
await wait();
@@ -395,16 +428,16 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should show error message instead of calling onSelect
// Should show error message instead of calling handleAuthSelect
expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
);
expect(onSelect).not.toHaveBeenCalled();
expect(handleAuthSelect).not.toHaveBeenCalled();
unmount();
});
it('should not exit if there is already an error message', async () => {
const onSelect = vi.fn();
const handleAuthSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -438,12 +471,10 @@ describe('AuthDialog', () => {
new Set(),
);
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog
onSelect={onSelect}
settings={settings}
initialErrorMessage="Initial error"
/>,
const { lastFrame, stdin, unmount } = renderAuthDialog(
settings,
{ authError: 'Initial error' },
{ handleAuthSelect },
);
await wait();
@@ -453,13 +484,13 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should not call onSelect
expect(onSelect).not.toHaveBeenCalled();
// Should not call handleAuthSelect
expect(handleAuthSelect).not.toHaveBeenCalled();
unmount();
});
it('should allow exiting when auth method is already selected', async () => {
const onSelect = vi.fn();
const handleAuthSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
@@ -493,8 +524,10 @@ describe('AuthDialog', () => {
new Set(),
);
const { stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
const { stdin, unmount } = renderAuthDialog(
settings,
{},
{ handleAuthSelect },
);
await wait();
@@ -502,8 +535,8 @@ describe('AuthDialog', () => {
stdin.write('\u001b'); // ESC key
await wait();
// Should call onSelect with undefined to exit
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
// Should call handleAuthSelect with undefined to exit
expect(handleAuthSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount();
});
});

View File

@@ -8,26 +8,14 @@ import type React from 'react';
import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import { validateAuthMethod } from '../../config/auth.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from '../components/OpenAIKeyPrompt.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
interface AuthDialogProps {
onSelect: (
authMethod: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => void;
settings: LoadedSettings;
initialErrorMessage?: string | null;
}
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { t } from '../../i18n/index.js';
function parseDefaultAuthType(
defaultAuthType: string | undefined,
@@ -41,31 +29,41 @@ function parseDefaultAuthType(
return null;
}
export function AuthDialog({
onSelect,
settings,
initialErrorMessage,
}: AuthDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(
initialErrorMessage || null,
);
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
export function AuthDialog(): React.JSX.Element {
const { pendingAuthType, authError } = useUIState();
const { handleAuthSelect: onAuthSelect } = useUIActions();
const settings = useSettings();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const items = [
{
key: AuthType.QWEN_OAUTH,
label: 'Qwen OAuth',
label: t('Qwen OAuth'),
value: AuthType.QWEN_OAUTH,
},
{ key: AuthType.USE_OPENAI, label: 'OpenAI', value: AuthType.USE_OPENAI },
{
key: AuthType.USE_OPENAI,
label: t('OpenAI'),
value: AuthType.USE_OPENAI,
},
];
const initialAuthIndex = Math.max(
0,
items.findIndex((item) => {
// Priority 1: pendingAuthType
if (pendingAuthType) {
return item.value === pendingAuthType;
}
// Priority 2: settings.merged.security?.auth?.selectedType
if (settings.merged.security?.auth?.selectedType) {
return item.value === settings.merged.security?.auth?.selectedType;
}
// Priority 3: QWEN_DEFAULT_AUTH_TYPE env var
const defaultAuthType = parseDefaultAuthType(
process.env['QWEN_DEFAULT_AUTH_TYPE'],
);
@@ -73,52 +71,29 @@ export function AuthDialog({
return item.value === defaultAuthType;
}
// Priority 4: default to QWEN_OAUTH
return item.value === AuthType.QWEN_OAUTH;
}),
);
const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethod(authMethod);
if (error) {
if (
authMethod === AuthType.USE_OPENAI &&
!process.env['OPENAI_API_KEY']
) {
setShowOpenAIKeyPrompt(true);
const hasApiKey = Boolean(settings.merged.security?.auth?.apiKey);
const currentSelectedAuthType =
selectedIndex !== null
? items[selectedIndex]?.value
: items[initialAuthIndex]?.value;
const handleAuthSelect = async (authMethod: AuthType) => {
setErrorMessage(null);
} else {
setErrorMessage(error);
}
} else {
setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
await onAuthSelect(authMethod, SettingScope.User);
};
const handleOpenAIKeySubmit = (
apiKey: string,
baseUrl: string,
model: string,
) => {
setShowOpenAIKeyPrompt(false);
onSelect(AuthType.USE_OPENAI, SettingScope.User, {
apiKey,
baseUrl,
model,
});
};
const handleOpenAIKeyCancel = () => {
setShowOpenAIKeyPrompt(false);
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
const handleHighlight = (authMethod: AuthType) => {
const index = items.findIndex((item) => item.value === authMethod);
setSelectedIndex(index);
};
useKeypress(
(key) => {
if (showOpenAIKeyPrompt) {
return;
}
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
@@ -128,25 +103,18 @@ export function AuthDialog({
if (settings.merged.security?.auth?.selectedType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
t(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
),
);
return;
}
onSelect(undefined, SettingScope.User);
onAuthSelect(undefined, SettingScope.User);
}
},
{ isActive: true },
);
if (showOpenAIKeyPrompt) {
return (
<OpenAIKeyPrompt
onSubmit={handleOpenAIKeySubmit}
onCancel={handleOpenAIKeyCancel}
/>
);
}
return (
<Box
borderStyle="round"
@@ -155,27 +123,37 @@ export function AuthDialog({
padding={1}
width="100%"
>
<Text bold>Get started</Text>
<Text bold>{t('Get started')}</Text>
<Box marginTop={1}>
<Text>How would you like to authenticate for this project?</Text>
<Text>{t('How would you like to authenticate for this project?')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
onHighlight={handleHighlight}
/>
</Box>
{errorMessage && (
{(authError || errorMessage) && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{errorMessage}</Text>
<Text color={Colors.AccentRed}>{authError || errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
<Text color={Colors.Gray}>
{t(
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
)}
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>

View File

@@ -10,6 +10,7 @@ import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
interface AuthInProgressProps {
onTimeout: () => void;
@@ -48,13 +49,13 @@ export function AuthInProgress({
>
{timedOut ? (
<Text color={theme.status.error}>
Authentication timed out. Please try again.
{t('Authentication timed out. Please try again.')}
</Text>
) : (
<Box>
<Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
cancel)
<Spinner type="dots" />{' '}
{t('Waiting for auth... (Press ESC or CTRL+C to cancel)')}
</Text>
</Box>
)}

View File

@@ -4,31 +4,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { AuthType, Config } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
clearCachedCredentialFile,
getErrorMessage,
logAuth,
} from '@qwen-code/qwen-code-core';
import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js';
import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { t } from '../../i18n/index.js';
export function validateAuthMethodWithSettings(
authType: AuthType,
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
export const useAuthCommand = (
settings: LoadedSettings,
): string | null {
const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
}
if (settings.merged.security?.auth?.useExternal) {
return null;
}
return validateAuthMethod(authType);
}
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
config: Config,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
) => {
const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined;
@@ -40,6 +38,14 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(unAuthenticated);
const [pendingAuthType, setPendingAuthType] = useState<AuthType | undefined>(
undefined,
);
const { qwenAuthState, cancelQwenAuth } = useQwenAuth(
pendingAuthType,
isAuthenticating,
);
const onAuthError = useCallback(
(error: string | null) => {
@@ -52,90 +58,136 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
[setAuthError, setAuthState],
);
// Authentication flow
useEffect(() => {
const authFlow = async () => {
const authType = settings.merged.security?.auth?.selectedType;
if (isAuthDialogOpen || !authType) {
return;
}
const validationError = validateAuthMethodWithSettings(
authType,
settings,
);
if (validationError) {
onAuthError(validationError);
return;
}
try {
setIsAuthenticating(true);
await config.refreshAuth(authType);
console.log(`Authenticated via "${authType}".`);
setAuthError(null);
setAuthState(AuthState.Authenticated);
} catch (e) {
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
} finally {
const handleAuthFailure = useCallback(
(error: unknown) => {
setIsAuthenticating(false);
}
};
void authFlow();
}, [isAuthDialogOpen, settings, config, onAuthError]);
// Handle auth selection from dialog
const handleAuthSelect = useCallback(
async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: {
apiKey?: string;
baseUrl?: string;
model?: string;
},
) => {
if (authType) {
await clearCachedCredentialFile();
// Save OpenAI credentials if provided
if (credentials) {
// Update Config's internal generationConfig before calling refreshAuth
// This ensures refreshAuth has access to the new credentials
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
const errorMessage = t('Failed to authenticate. Message: {{message}}', {
message: getErrorMessage(error),
});
onAuthError(errorMessage);
// Also set environment variables for compatibility with other parts of the code
if (credentials.apiKey) {
// Log authentication failure
if (pendingAuthType) {
const authEvent = new AuthEvent(
pendingAuthType,
'manual',
'error',
errorMessage,
);
logAuth(config, authEvent);
}
},
[onAuthError, pendingAuthType, config],
);
const handleAuthSuccess = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try {
settings.setValue(scope, 'security.auth.selectedType', authType);
// Only update credentials if not switching to QWEN_OAUTH,
// so that OpenAI credentials are preserved when switching to QWEN_OAUTH.
if (authType !== AuthType.QWEN_OAUTH && credentials) {
if (credentials?.apiKey != null) {
settings.setValue(
scope,
'security.auth.apiKey',
credentials.apiKey,
);
}
if (credentials.baseUrl) {
if (credentials?.baseUrl != null) {
settings.setValue(
scope,
'security.auth.baseUrl',
credentials.baseUrl,
);
}
if (credentials.model) {
if (credentials?.model != null) {
settings.setValue(scope, 'model.name', credentials.model);
}
await clearCachedCredentialFile();
}
} catch (error) {
handleAuthFailure(error);
return;
}
settings.setValue(scope, 'security.auth.selectedType', authType);
}
setAuthError(null);
setAuthState(AuthState.Authenticated);
setPendingAuthType(undefined);
setIsAuthDialogOpen(false);
setIsAuthenticating(false);
// Log authentication success
const authEvent = new AuthEvent(authType, 'manual', 'success');
logAuth(config, authEvent);
// Show success message
addItem(
{
type: MessageType.INFO,
text: t('Authenticated successfully with {{authType}} credentials.', {
authType,
}),
},
Date.now(),
);
},
[settings, handleAuthFailure, config, addItem],
);
const performAuth = useCallback(
async (
authType: AuthType,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
try {
await config.refreshAuth(authType);
handleAuthSuccess(authType, scope, credentials);
} catch (e) {
handleAuthFailure(e);
}
},
[config, handleAuthSuccess, handleAuthFailure],
);
const handleAuthSelect = useCallback(
async (
authType: AuthType | undefined,
scope: SettingScope,
credentials?: OpenAICredentials,
) => {
if (!authType) {
setIsAuthDialogOpen(false);
setAuthError(null);
return;
}
setPendingAuthType(authType);
setAuthError(null);
setIsAuthDialogOpen(false);
setIsAuthenticating(true);
if (authType === AuthType.USE_OPENAI) {
if (credentials) {
config.updateCredentials({
apiKey: credentials.apiKey,
baseUrl: credentials.baseUrl,
model: credentials.model,
});
await performAuth(authType, scope, credentials);
}
return;
}
await performAuth(authType, scope);
},
[settings, config],
[config, performAuth],
);
const openAuthDialog = useCallback(() => {
@@ -143,8 +195,51 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
}, []);
const cancelAuthentication = useCallback(() => {
if (isAuthenticating && pendingAuthType === AuthType.QWEN_OAUTH) {
cancelQwenAuth();
}
// Log authentication cancellation
if (isAuthenticating && pendingAuthType) {
const authEvent = new AuthEvent(pendingAuthType, 'manual', 'cancelled');
logAuth(config, authEvent);
}
// Do not reset pendingAuthType here, persist the previously selected type.
setIsAuthenticating(false);
}, []);
setIsAuthDialogOpen(true);
setAuthError(null);
}, [isAuthenticating, pendingAuthType, cancelQwenAuth, config]);
/**
/**
* We previously used a useEffect to trigger authentication automatically when
* settings.security.auth.selectedType changed. This caused problems: if authentication failed,
* the UI could get stuck, since settings.json would update before success. Now, we
* update selectedType in settings only when authentication fully succeeds.
* Authentication is triggered explicitly—either during initial app startup or when the
* user switches methods—not reactively through settings changes. This avoids repeated
* or broken authentication cycles.
*/
useEffect(() => {
const defaultAuthType = process.env['QWEN_DEFAULT_AUTH_TYPE'];
if (
defaultAuthType &&
![AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].includes(
defaultAuthType as AuthType,
)
) {
onAuthError(
t(
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}',
{
value: defaultAuthType,
validValues: [AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', '),
},
),
);
}
}, [onAuthError]);
return {
authState,
@@ -153,6 +248,8 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
onAuthError,
isAuthDialogOpen,
isAuthenticating,
pendingAuthType,
qwenAuthState,
handleAuthSelect,
openAuthDialog,
cancelAuthentication,

View File

@@ -8,38 +8,22 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { aboutCommand } from './aboutCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
import * as systemInfoUtils from '../../utils/systemInfo.js';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
}),
},
};
});
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
}));
vi.mock('../../utils/systemInfo.js');
describe('aboutCommand', () => {
let mockContext: CommandContext;
const originalPlatform = process.platform;
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn(),
getModel: vi.fn().mockReturnValue('test-model'),
getIdeMode: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
},
settings: {
merged: {
@@ -56,21 +40,25 @@ describe('aboutCommand', () => {
},
} as unknown as CommandContext);
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
'test-model',
);
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
Object.defineProperty(process, 'platform', {
value: 'test-os',
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
process.env = originalEnv;
vi.clearAllMocks();
});
@@ -81,30 +69,55 @@ describe('aboutCommand', () => {
});
it('should call addItem with all version info', async () => {
process.env['SANDBOX'] = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(systemInfoUtils.getExtendedSystemInfo).toHaveBeenCalledWith(
mockContext,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
expect.objectContaining({
type: MessageType.ABOUT,
systemInfo: expect.objectContaining({
cliVersion: 'test-version',
osVersion: 'test-os',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-gcp-project',
ideClient: 'test-ide',
},
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
}),
}),
expect.any(Number),
);
});
it('should show the correct sandbox environment variable', async () => {
process.env['SANDBOX'] = 'gemini-sandbox';
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'gemini-sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -113,15 +126,32 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ABOUT,
systemInfo: expect.objectContaining({
sandboxEnv: 'gemini-sandbox',
}),
}),
expect.any(Number),
);
});
it('should show sandbox-exec profile when applicable', async () => {
process.env['SANDBOX'] = 'sandbox-exec';
process.env['SEATBELT_PROFILE'] = 'test-profile';
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'sandbox-exec (test-profile)',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -130,18 +160,31 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
systemInfo: expect.objectContaining({
sandboxEnv: 'sandbox-exec (test-profile)',
}),
}),
expect.any(Number),
);
});
it('should not show ide client when it is not detected', async () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue({
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
} as unknown as IdeClient);
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: '',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
process.env['SANDBOX'] = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
@@ -151,13 +194,87 @@ describe('aboutCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ABOUT,
systemInfo: expect.objectContaining({
cliVersion: 'test-version',
osVersion: 'test-os',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
gcpProject: 'test-gcp-project',
ideClient: '',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
}),
}),
expect.any(Number),
);
});
it('should show unknown npmVersion when npm command fails', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: 'unknown',
sandboxEnv: 'no sandbox',
modelVersion: 'test-model',
selectedAuthType: 'test-auth',
ideClient: 'test-ide',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
systemInfo: expect.objectContaining({
npmVersion: 'unknown',
}),
}),
expect.any(Number),
);
});
it('should show unknown sessionId when config is not available', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: 'test-version',
osPlatform: 'test-os',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'no sandbox',
modelVersion: 'Unknown',
selectedAuthType: 'test-auth',
ideClient: '',
sessionId: 'unknown',
memoryUsage: '100 MB',
baseUrl: undefined,
});
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}
await aboutCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
systemInfo: expect.objectContaining({
sessionId: 'unknown',
}),
}),
expect.any(Number),
);

View File

@@ -4,53 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getCliVersion } from '../../utils/version.js';
import type { CommandContext, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
import { IdeClient } from '@qwen-code/qwen-code-core';
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
import { t } from '../../i18n/index.js';
export const aboutCommand: SlashCommand = {
name: 'about',
description: 'show version info',
get description() {
return t('show version info');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const osVersion = process.platform;
let sandboxEnv = 'no sandbox';
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
sandboxEnv = process.env['SANDBOX'];
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
sandboxEnv = `sandbox-exec (${
process.env['SEATBELT_PROFILE'] || 'unknown'
})`;
}
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient = await getIdeClientName(context);
const systemInfo = await getExtendedSystemInfo(context);
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
type: MessageType.ABOUT,
cliVersion,
osVersion,
sandboxEnv,
modelVersion,
selectedAuthType,
gcpProject,
ideClient,
systemInfo,
};
context.ui.addItem(aboutItem, Date.now());
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient?.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -9,15 +9,20 @@ import {
type SlashCommand,
type OpenDialogActionReturn,
} from './types.js';
import { t } from '../../i18n/index.js';
export const agentsCommand: SlashCommand = {
name: 'agents',
description: 'Manage subagents for specialized task delegation.',
get description() {
return t('Manage subagents for specialized task delegation.');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'manage',
description: 'Manage existing subagents (view, edit, delete).',
get description() {
return t('Manage existing subagents (view, edit, delete).');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
@@ -26,7 +31,9 @@ export const agentsCommand: SlashCommand = {
},
{
name: 'create',
description: 'Create a new subagent with guided setup.',
get description() {
return t('Create a new subagent with guided setup.');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -4,492 +4,68 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { approvalModeCommand } from './approvalModeCommand.js';
import {
type CommandContext,
CommandKind,
type MessageActionReturn,
type OpenDialogActionReturn,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
describe('approvalModeCommand', () => {
let mockContext: CommandContext;
let setApprovalModeMock: ReturnType<typeof vi.fn>;
let setSettingsValueMock: ReturnType<typeof vi.fn>;
const originalEnv = { ...process.env };
const userSettingsPath = '/mock/user/settings.json';
const projectSettingsPath = '/mock/project/settings.json';
const userSettingsFile = { path: userSettingsPath, settings: {} };
const projectSettingsFile = { path: projectSettingsPath, settings: {} };
const getModeSubCommand = (mode: ApprovalMode) =>
approvalModeCommand.subCommands?.find((cmd) => cmd.name === mode);
const getScopeSubCommand = (
mode: ApprovalMode,
scope: '--session' | '--user' | '--project',
) => getModeSubCommand(mode)?.subCommands?.find((cmd) => cmd.name === scope);
beforeEach(() => {
setApprovalModeMock = vi.fn();
setSettingsValueMock = vi.fn();
mockContext = createMockCommandContext({
services: {
config: {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: setApprovalModeMock,
getApprovalMode: () => 'default',
setApprovalMode: () => {},
},
settings: {
merged: {},
setValue: setSettingsValueMock,
forScope: vi
.fn()
.mockImplementation((scope: SettingScope) =>
scope === SettingScope.User
? userSettingsFile
: scope === SettingScope.Workspace
? projectSettingsFile
: { path: '', settings: {} },
),
setValue: () => {},
forScope: () => ({}),
} as unknown as LoadedSettings,
},
} as unknown as CommandContext);
});
});
afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
});
it('should have the correct command properties', () => {
it('should have correct metadata', () => {
expect(approvalModeCommand.name).toBe('approval-mode');
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
expect(approvalModeCommand.description).toBe(
'View or change the approval mode for tool usage',
);
expect(approvalModeCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should show current mode, options, and usage when no arguments provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
it('should open approval mode dialog when invoked', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'',
)) as MessageActionReturn;
)) as OpenDialogActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
const expectedMessage = [
'Current approval mode: default',
'',
'Available approval modes:',
' - plan: Plan mode - Analyze only, do not modify files or execute commands',
' - default: Default mode - Require approval for file edits or shell commands',
' - auto-edit: Auto-edit mode - Automatically approve file edits',
' - yolo: YOLO mode - Automatically approve all tools',
'',
'Usage: /approval-mode <mode> [--session|--user|--project]',
].join('\n');
expect(result.content).toBe(expectedMessage);
expect(result.type).toBe('dialog');
expect(result.dialog).toBe('approval-mode');
});
it('should display error when config is not available', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
it('should open approval mode dialog with arguments (ignored)', async () => {
const result = (await approvalModeCommand.action?.(
mockContext,
'some arguments',
)) as OpenDialogActionReturn;
const nullConfigContext = createMockCommandContext({
services: {
config: null,
},
} as unknown as CommandContext);
const result = (await approvalModeCommand.action(
nullConfigContext,
'',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe('Configuration not available.');
expect(result.type).toBe('dialog');
expect(result.dialog).toBe('approval-mode');
});
it('should change approval mode when valid mode is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'plan',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toBe('Approval mode changed to: plan');
it('should not have subcommands', () => {
expect(approvalModeCommand.subCommands).toBeUndefined();
});
it('should accept canonical auto-edit mode value', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.type).toBe('message');
expect(result.messageType).toBe('info');
expect(result.content).toBe('Approval mode changed to: auto-edit');
});
it('should accept auto-edit alias for compatibility', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.content).toBe('Approval mode changed to: auto-edit');
});
it('should display error when invalid mode is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'invalid',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Invalid approval mode: invalid');
expect(result.content).toContain('Available approval modes:');
expect(result.content).toContain(
'Usage: /approval-mode <mode> [--session|--user|--project]',
);
});
it('should display error when setApprovalMode throws an error', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const errorMessage = 'Failed to set approval mode';
mockContext.services.config!.setApprovalMode = vi
.fn()
.mockImplementation(() => {
throw new Error(errorMessage);
});
const result = (await approvalModeCommand.action(
mockContext,
'plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(
`Failed to change approval mode: ${errorMessage}`,
);
});
it('should allow selecting auto-edit with user scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const userSubCommand = getScopeSubCommand(ApprovalMode.AUTO_EDIT, '--user');
if (!userSubCommand?.action) {
throw new Error('--user scope subcommand must have an action.');
}
const result = (await userSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'auto-edit',
);
expect(result.content).toBe(
`Approval mode changed to: auto-edit (saved to user settings at ${userSettingsPath})`,
);
});
it('should allow selecting plan with project scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const projectSubCommand = getScopeSubCommand(
ApprovalMode.PLAN,
'--project',
);
if (!projectSubCommand?.action) {
throw new Error('--project scope subcommand must have an action.');
}
const result = (await projectSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.Workspace,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to project settings at ${projectSettingsPath})`,
);
});
it('should allow selecting plan with session scope via nested subcommands', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const sessionSubCommand = getScopeSubCommand(
ApprovalMode.PLAN,
'--session',
);
if (!sessionSubCommand?.action) {
throw new Error('--session scope subcommand must have an action.');
}
const result = (await sessionSubCommand.action(
mockContext,
'',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).not.toHaveBeenCalled();
expect(result.content).toBe('Approval mode changed to: plan');
});
it('should allow providing a scope argument after selecting a mode subcommand', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const planSubCommand = getModeSubCommand(ApprovalMode.PLAN);
if (!planSubCommand?.action) {
throw new Error('plan subcommand must have an action.');
}
const result = (await planSubCommand.action(
mockContext,
'--user',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support --user plan pattern (scope first)', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user plan',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support plan --user pattern (mode first)', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'plan --user',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.PLAN);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.User,
'approvalMode',
'plan',
);
expect(result.content).toBe(
`Approval mode changed to: plan (saved to user settings at ${userSettingsPath})`,
);
});
it('should support --project auto-edit pattern', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--project auto-edit',
)) as MessageActionReturn;
expect(setApprovalModeMock).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
expect(setSettingsValueMock).toHaveBeenCalledWith(
SettingScope.Workspace,
'approvalMode',
'auto-edit',
);
expect(result.content).toBe(
`Approval mode changed to: auto-edit (saved to project settings at ${projectSettingsPath})`,
);
});
it('should display error when only scope flag is provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Missing approval mode');
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should display error when multiple scope flags are provided', async () => {
if (!approvalModeCommand.action) {
throw new Error('approvalModeCommand must have an action.');
}
const result = (await approvalModeCommand.action(
mockContext,
'--user --project plan',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toContain('Multiple scope flags provided');
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should surface a helpful error when scope subcommands receive extra arguments', async () => {
if (!approvalModeCommand.subCommands) {
throw new Error('approvalModeCommand must have subCommands.');
}
const userSubCommand = getScopeSubCommand(ApprovalMode.DEFAULT, '--user');
if (!userSubCommand?.action) {
throw new Error('--user scope subcommand must have an action.');
}
const result = (await userSubCommand.action(
mockContext,
'extra',
)) as MessageActionReturn;
expect(result.type).toBe('message');
expect(result.messageType).toBe('error');
expect(result.content).toBe(
'Scope subcommands do not accept additional arguments.',
);
expect(setApprovalModeMock).not.toHaveBeenCalled();
expect(setSettingsValueMock).not.toHaveBeenCalled();
});
it('should provide completion for approval modes', async () => {
if (!approvalModeCommand.completion) {
throw new Error('approvalModeCommand must have a completion function.');
}
// Test partial mode completion
const result = await approvalModeCommand.completion(mockContext, 'p');
expect(result).toEqual(['plan']);
const result2 = await approvalModeCommand.completion(mockContext, 'a');
expect(result2).toEqual(['auto-edit']);
// Test empty completion - should suggest available modes first
const result3 = await approvalModeCommand.completion(mockContext, '');
expect(result3).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
const result4 = await approvalModeCommand.completion(mockContext, 'AUTO');
expect(result4).toEqual(['auto-edit']);
// Test mode first pattern: 'plan ' should suggest scope flags
const result5 = await approvalModeCommand.completion(mockContext, 'plan ');
expect(result5).toEqual(['--session', '--project', '--user']);
const result6 = await approvalModeCommand.completion(
mockContext,
'plan --u',
);
expect(result6).toEqual(['--user']);
// Test scope first pattern: '--user ' should suggest modes
const result7 = await approvalModeCommand.completion(
mockContext,
'--user ',
);
expect(result7).toEqual(['plan', 'default', 'auto-edit', 'yolo']);
const result8 = await approvalModeCommand.completion(
mockContext,
'--user p',
);
expect(result8).toEqual(['plan']);
// Test completed patterns should return empty
const result9 = await approvalModeCommand.completion(
mockContext,
'plan --user ',
);
expect(result9).toEqual([]);
const result10 = await approvalModeCommand.completion(
mockContext,
'--user plan ',
);
expect(result10).toEqual([]);
it('should not have completion function', () => {
expect(approvalModeCommand.completion).toBeUndefined();
});
});

View File

@@ -7,428 +7,22 @@
import type {
SlashCommand,
CommandContext,
MessageActionReturn,
OpenDialogActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { ApprovalMode, APPROVAL_MODES } from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
const USAGE_MESSAGE =
'Usage: /approval-mode <mode> [--session|--user|--project]';
const normalizeInputMode = (value: string): string =>
value.trim().toLowerCase();
const tokenizeArgs = (args: string): string[] => {
const matches = args.match(/(?:"[^"]*"|'[^']*'|[^\s"']+)/g);
if (!matches) {
return [];
}
return matches.map((token) => {
if (
(token.startsWith('"') && token.endsWith('"')) ||
(token.startsWith("'") && token.endsWith("'"))
) {
return token.slice(1, -1);
}
return token;
});
};
const parseApprovalMode = (value: string | null): ApprovalMode | null => {
if (!value) {
return null;
}
const normalized = normalizeInputMode(value).replace(/_/g, '-');
const matchIndex = APPROVAL_MODES.findIndex(
(candidate) => candidate === normalized,
);
return matchIndex === -1 ? null : APPROVAL_MODES[matchIndex];
};
const formatModeDescription = (mode: ApprovalMode): string => {
switch (mode) {
case ApprovalMode.PLAN:
return 'Plan mode - Analyze only, do not modify files or execute commands';
case ApprovalMode.DEFAULT:
return 'Default mode - Require approval for file edits or shell commands';
case ApprovalMode.AUTO_EDIT:
return 'Auto-edit mode - Automatically approve file edits';
case ApprovalMode.YOLO:
return 'YOLO mode - Automatically approve all tools';
default:
return `${mode} mode`;
}
};
const parseApprovalArgs = (
args: string,
): {
mode: string | null;
scope: 'session' | 'user' | 'project';
error?: string;
} => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return { mode: null, scope: 'session' };
}
const tokens = tokenizeArgs(trimmedArgs);
let mode: string | null = null;
let scope: 'session' | 'user' | 'project' = 'session';
let scopeFlag: string | null = null;
// Find scope flag and mode
for (const token of tokens) {
if (token === '--session' || token === '--user' || token === '--project') {
if (scopeFlag) {
return {
mode: null,
scope: 'session',
error: 'Multiple scope flags provided',
};
}
scopeFlag = token;
scope = token.substring(2) as 'session' | 'user' | 'project';
} else if (!mode) {
mode = token;
} else {
return {
mode: null,
scope: 'session',
error: 'Invalid arguments provided',
};
}
}
if (!mode) {
return { mode: null, scope: 'session', error: 'Missing approval mode' };
}
return { mode, scope };
};
const setApprovalModeWithScope = async (
context: CommandContext,
mode: ApprovalMode,
scope: 'session' | 'user' | 'project',
): Promise<MessageActionReturn> => {
const { services } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
try {
// Always set the mode in the current session
config.setApprovalMode(mode);
// If scope is not session, also persist to settings
if (scope !== 'session') {
const { settings } = context.services;
if (!settings || typeof settings.setValue !== 'function') {
return {
type: 'message',
messageType: 'error',
content:
'Settings service is not available; unable to persist the approval mode.',
};
}
const settingScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const scopeLabel = scope === 'user' ? 'user' : 'project';
let settingsPath: string | undefined;
try {
if (typeof settings.forScope === 'function') {
settingsPath = settings.forScope(settingScope)?.path;
}
} catch (_error) {
settingsPath = undefined;
}
try {
settings.setValue(settingScope, 'approvalMode', mode);
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to save approval mode: ${(error as Error).message}`,
};
}
const locationSuffix = settingsPath ? ` at ${settingsPath}` : '';
const scopeSuffix = ` (saved to ${scopeLabel} settings${locationSuffix})`;
return {
type: 'message',
messageType: 'info',
content: `Approval mode changed to: ${mode}${scopeSuffix}`,
};
}
return {
type: 'message',
messageType: 'info',
content: `Approval mode changed to: ${mode}`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to change approval mode: ${(error as Error).message}`,
};
}
};
import { t } from '../../i18n/index.js';
export const approvalModeCommand: SlashCommand = {
name: 'approval-mode',
description: 'View or change the approval mode for tool usage',
get description() {
return t('View or change the approval mode for tool usage');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
// If no arguments provided, show current mode and available options
if (!args || args.trim() === '') {
const currentMode =
typeof config.getApprovalMode === 'function'
? config.getApprovalMode()
: null;
const messageLines: string[] = [];
if (currentMode) {
messageLines.push(`Current approval mode: ${currentMode}`);
messageLines.push('');
}
messageLines.push('Available approval modes:');
for (const mode of APPROVAL_MODES) {
messageLines.push(` - ${mode}: ${formatModeDescription(mode)}`);
}
messageLines.push('');
messageLines.push(USAGE_MESSAGE);
return {
type: 'message',
messageType: 'info',
content: messageLines.join('\n'),
};
}
// Parse arguments flexibly
const parsed = parseApprovalArgs(args);
if (parsed.error) {
return {
type: 'message',
messageType: 'error',
content: `${parsed.error}. ${USAGE_MESSAGE}`,
};
}
if (!parsed.mode) {
return {
type: 'message',
messageType: 'info',
content: USAGE_MESSAGE,
};
}
const requestedMode = parseApprovalMode(parsed.mode);
if (!requestedMode) {
let message = `Invalid approval mode: ${parsed.mode}\n\n`;
message += 'Available approval modes:\n';
for (const mode of APPROVAL_MODES) {
message += ` - ${mode}: ${formatModeDescription(mode)}\n`;
}
message += `\n${USAGE_MESSAGE}`;
return {
type: 'message',
messageType: 'error',
content: message,
};
}
return setApprovalModeWithScope(context, requestedMode, parsed.scope);
},
subCommands: APPROVAL_MODES.map((mode) => ({
name: mode,
description: formatModeDescription(mode),
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: '--session',
description: 'Apply to current session only (temporary)',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'session');
},
},
{
name: '--project',
description: 'Persist for this project/workspace',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'project');
},
},
{
name: '--user',
description: 'Persist for this user on this machine',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: 'Scope subcommands do not accept additional arguments.',
};
}
return setApprovalModeWithScope(context, mode, 'user');
},
},
],
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
// Allow users who type `/approval-mode plan --user` via the subcommand path
const parsed = parseApprovalArgs(`${mode} ${args}`);
if (parsed.error) {
return {
type: 'message',
messageType: 'error',
content: `${parsed.error}. ${USAGE_MESSAGE}`,
};
}
const normalizedMode = parseApprovalMode(parsed.mode);
if (!normalizedMode) {
return {
type: 'message',
messageType: 'error',
content: `Invalid approval mode: ${parsed.mode}. ${USAGE_MESSAGE}`,
};
}
return setApprovalModeWithScope(context, normalizedMode, parsed.scope);
}
return setApprovalModeWithScope(context, mode, 'session');
},
})),
completion: async (_context: CommandContext, partialArg: string) => {
const tokens = tokenizeArgs(partialArg);
const hasTrailingSpace = /\s$/.test(partialArg);
const currentSegment = hasTrailingSpace
? ''
: tokens.length > 0
? tokens[tokens.length - 1]
: '';
const normalizedCurrent = normalizeInputMode(currentSegment).replace(
/_/g,
'-',
);
const scopeValues = ['--session', '--project', '--user'];
const normalizeToken = (token: string) =>
normalizeInputMode(token).replace(/_/g, '-');
const normalizedTokens = tokens.map(normalizeToken);
if (tokens.length === 0) {
if (currentSegment.startsWith('-')) {
return scopeValues.filter((scope) => scope.startsWith(currentSegment));
}
return APPROVAL_MODES;
}
if (tokens.length === 1 && !hasTrailingSpace) {
const originalToken = tokens[0];
if (originalToken.startsWith('-')) {
return scopeValues.filter((scope) =>
scope.startsWith(normalizedCurrent),
);
}
return APPROVAL_MODES.filter((mode) =>
mode.startsWith(normalizedCurrent),
);
}
if (tokens.length === 1 && hasTrailingSpace) {
const normalizedFirst = normalizedTokens[0];
if (scopeValues.includes(tokens[0])) {
return APPROVAL_MODES;
}
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
return scopeValues;
}
return APPROVAL_MODES;
}
if (tokens.length === 2 && !hasTrailingSpace) {
const normalizedFirst = normalizedTokens[0];
if (scopeValues.includes(tokens[0])) {
return APPROVAL_MODES.filter((mode) =>
mode.startsWith(normalizedCurrent),
);
}
if (APPROVAL_MODES.includes(normalizedFirst as ApprovalMode)) {
return scopeValues.filter((scope) =>
scope.startsWith(normalizedCurrent),
);
}
return [];
}
return [];
},
_context: CommandContext,
_args: string,
): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'approval-mode',
}),
};

View File

@@ -6,10 +6,13 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const authCommand: SlashCommand = {
name: 'auth',
description: 'change the auth method',
get description() {
return t('change the auth method');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -8,41 +8,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import open from 'open';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getCliVersion } from '../../utils/version.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import * as systemInfoUtils from '../../utils/systemInfo.js';
// Mock dependencies
vi.mock('open');
vi.mock('../../utils/version.js');
vi.mock('../utils/formatters.js');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
IdeClient: {
getInstance: () => ({
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
}),
},
};
});
vi.mock('node:process', () => ({
default: {
platform: 'test-platform',
version: 'v20.0.0',
// Keep other necessary process properties if needed by other parts of the code
env: process.env,
memoryUsage: () => ({ rss: 0 }),
},
}));
vi.mock('../../utils/systemInfo.js');
describe('bugCommand', () => {
beforeEach(() => {
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: '0.1.0',
osPlatform: 'test-platform',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'test',
modelVersion: 'qwen3-coder-plus',
selectedAuthType: '',
ideClient: 'VSCode',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
gitCommit:
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? GIT_COMMIT_INFO
: undefined,
});
vi.stubEnv('SANDBOX', 'qwen-test');
});
@@ -55,19 +48,7 @@ describe('bugCommand', () => {
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
getIdeMode: () => true,
getSessionId: () => 'test-session-id',
},
settings: {
merged: {
security: {
auth: {
selectedType: undefined,
},
},
},
},
},
});
@@ -75,14 +56,21 @@ describe('bugCommand', () => {
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'A test bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Auth Type:**
* **Model Version:** qwen3-coder-plus
* **Auth Method:**
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
@@ -99,19 +87,7 @@ describe('bugCommand', () => {
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => ({ urlTemplate: customTemplate }),
getIdeMode: () => true,
getSessionId: () => 'test-session-id',
},
settings: {
merged: {
security: {
auth: {
selectedType: undefined,
},
},
},
},
},
});
@@ -119,14 +95,21 @@ describe('bugCommand', () => {
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'A custom bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Auth Type:**
* **Model Version:** qwen3-coder-plus
* **Auth Method:**
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;
@@ -138,25 +121,30 @@ describe('bugCommand', () => {
});
it('should include Base URL when auth type is OpenAI', async () => {
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
cliVersion: '0.1.0',
osPlatform: 'test-platform',
osArch: 'x64',
osRelease: '22.0.0',
nodeVersion: 'v20.0.0',
npmVersion: '10.0.0',
sandboxEnv: 'test',
modelVersion: 'qwen3-coder-plus',
selectedAuthType: AuthType.USE_OPENAI,
ideClient: 'VSCode',
sessionId: 'test-session-id',
memoryUsage: '100 MB',
baseUrl: 'https://api.openai.com/v1',
gitCommit:
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? GIT_COMMIT_INFO
: undefined,
});
const mockContext = createMockCommandContext({
services: {
config: {
getModel: () => 'qwen3-coder-plus',
getBugCommand: () => undefined,
getIdeMode: () => true,
getSessionId: () => 'test-session-id',
getContentGeneratorConfig: () => ({
baseUrl: 'https://api.openai.com/v1',
}),
},
settings: {
merged: {
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
},
},
},
});
@@ -164,15 +152,22 @@ describe('bugCommand', () => {
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'OpenAI bug');
const gitCommitLine =
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
: '';
const expectedInfo = `
* **CLI Version:** 0.1.0
* **Git Commit:** ${GIT_COMMIT_INFO}
${gitCommitLine}* **Model:** qwen3-coder-plus
* **Sandbox:** test
* **OS Platform:** test-platform
* **OS Arch:** x64
* **OS Release:** 22.0.0
* **Node.js Version:** v20.0.0
* **NPM Version:** 10.0.0
* **Session ID:** test-session-id
* **Operating System:** test-platform v20.0.0
* **Sandbox Environment:** test
* **Auth Type:** ${AuthType.USE_OPENAI}
* **Auth Method:** ${AuthType.USE_OPENAI}
* **Base URL:** https://api.openai.com/v1
* **Model Version:** qwen3-coder-plus
* **Memory Usage:** 100 MB
* **IDE Client:** VSCode
`;

View File

@@ -5,68 +5,41 @@
*/
import open from 'open';
import process from 'node:process';
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js';
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
import {
getSystemInfoFields,
getFieldValue,
} from '../../utils/systemInfoFields.js';
import { t } from '../../i18n/index.js';
export const bugCommand: SlashCommand = {
name: 'bug',
description: 'submit a bug report',
get description() {
return t('submit a bug report');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim();
const { config } = context.services;
const systemInfo = await getExtendedSystemInfo(context);
const osVersion = `${process.platform} ${process.version}`;
let sandboxEnv = 'no sandbox';
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
sandboxEnv = process.env['SANDBOX'].replace(/^qwen-(?:code-)?/, '');
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
sandboxEnv = `sandbox-exec (${
process.env['SEATBELT_PROFILE'] || 'unknown'
})`;
}
const modelVersion = config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
const ideClient = await getIdeClientName(context);
const selectedAuthType =
context.services.settings.merged.security?.auth?.selectedType || '';
const baseUrl =
selectedAuthType === AuthType.USE_OPENAI
? config?.getContentGeneratorConfig()?.baseUrl
: undefined;
const fields = getSystemInfoFields(systemInfo);
let info = `
* **CLI Version:** ${cliVersion}
* **Git Commit:** ${GIT_COMMIT_INFO}
* **Session ID:** ${config?.getSessionId() || 'unknown'}
* **Operating System:** ${osVersion}
* **Sandbox Environment:** ${sandboxEnv}
* **Auth Type:** ${selectedAuthType}`;
if (baseUrl) {
info += `\n* **Base URL:** ${baseUrl}`;
}
info += `
* **Model Version:** ${modelVersion}
* **Memory Usage:** ${memoryUsage}
`;
if (ideClient) {
info += `* **IDE Client:** ${ideClient}\n`;
// Generate bug report info using the same field configuration
let info = '\n';
for (const field of fields) {
info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`;
}
let bugReportUrl =
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
const bugCommandSettings = config?.getBugCommand();
const bugCommandSettings = context.services.config?.getBugCommand();
if (bugCommandSettings?.urlTemplate) {
bugReportUrl = bugCommandSettings.urlTemplate;
}
@@ -98,11 +71,3 @@ export const bugCommand: SlashCommand = {
}
},
};
async function getIdeClientName(context: CommandContext) {
if (!context.services.config?.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient.getDetectedIdeDisplayName() ?? '';
}

View File

@@ -7,7 +7,6 @@
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type {
CommandContext,
SlashCommand,
@@ -20,6 +19,7 @@ import path from 'node:path';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
import type { Content } from '@google/genai';
import { t } from '../../i18n/index.js';
interface ChatDetail {
name: string;
@@ -67,7 +67,9 @@ const getSavedChatTags = async (
const listCommand: SlashCommand = {
name: 'list',
description: 'List saved conversation checkpoints',
get description() {
return t('List saved conversation checkpoints');
},
kind: CommandKind.BUILT_IN,
action: async (context): Promise<MessageActionReturn> => {
const chatDetails = await getSavedChatTags(context, false);
@@ -75,7 +77,7 @@ const listCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: 'No saved conversation checkpoints found.',
content: t('No saved conversation checkpoints found.'),
};
}
@@ -83,7 +85,7 @@ const listCommand: SlashCommand = {
...chatDetails.map((chat) => chat.name.length),
);
let message = 'List of saved conversations:\n\n';
let message = t('List of saved conversations:') + '\n\n';
for (const chat of chatDetails) {
const paddedName = chat.name.padEnd(maxNameLength, ' ');
const isoString = chat.mtime.toISOString();
@@ -91,7 +93,7 @@ const listCommand: SlashCommand = {
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
message += ` - ${paddedName} (saved on ${formattedDate})\n`;
}
message += `\nNote: Newest last, oldest first`;
message += `\n${t('Note: Newest last, oldest first')}`;
return {
type: 'message',
messageType: 'info',
@@ -102,8 +104,11 @@ const listCommand: SlashCommand = {
const saveCommand: SlashCommand = {
name: 'save',
description:
get description() {
return t(
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
);
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim();
@@ -111,7 +116,7 @@ const saveCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat save <tag>',
content: t('Missing tag. Usage: /chat save <tag>'),
};
}
@@ -126,9 +131,12 @@ const saveCommand: SlashCommand = {
prompt: React.createElement(
Text,
null,
'A checkpoint with the tag ',
React.createElement(Text, { color: theme.text.accent }, tag),
' already exists. Do you want to overwrite it?',
t(
'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?',
{
tag,
},
),
),
originalInvocation: {
raw: context.invocation?.raw || `/chat save ${tag}`,
@@ -142,7 +150,7 @@ const saveCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to save conversation.',
content: t('No chat client available to save conversation.'),
};
}
@@ -152,13 +160,15 @@ const saveCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${decodeTagName(tag)}.`,
content: t('Conversation checkpoint saved with tag: {{tag}}.', {
tag: decodeTagName(tag),
}),
};
} else {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
content: t('No conversation found to save.'),
};
}
},
@@ -167,8 +177,11 @@ const saveCommand: SlashCommand = {
const resumeCommand: SlashCommand = {
name: 'resume',
altNames: ['load'],
description:
get description() {
return t(
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
);
},
kind: CommandKind.BUILT_IN,
action: async (context, args) => {
const tag = args.trim();
@@ -176,7 +189,7 @@ const resumeCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat resume <tag>',
content: t('Missing tag. Usage: /chat resume <tag>'),
};
}
@@ -188,7 +201,9 @@ const resumeCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`,
content: t('No saved checkpoint found with tag: {{tag}}.', {
tag: decodeTagName(tag),
}),
};
}
@@ -237,7 +252,9 @@ const resumeCommand: SlashCommand = {
const deleteCommand: SlashCommand = {
name: 'delete',
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
get description() {
return t('Delete a conversation checkpoint. Usage: /chat delete <tag>');
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
@@ -245,7 +262,7 @@ const deleteCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
content: t('Missing tag. Usage: /chat delete <tag>'),
};
}
@@ -257,13 +274,17 @@ const deleteCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`,
content: t("Conversation checkpoint '{{tag}}' has been deleted.", {
tag: decodeTagName(tag),
}),
};
} else {
return {
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`,
content: t("Error: No checkpoint found with tag '{{tag}}'.", {
tag: decodeTagName(tag),
}),
};
}
},
@@ -309,8 +330,11 @@ export function serializeHistoryToMarkdown(history: Content[]): string {
const shareCommand: SlashCommand = {
name: 'share',
description:
get description() {
return t(
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
);
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
let filePathArg = args.trim();
@@ -324,7 +348,7 @@ const shareCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
content: t('Invalid file format. Only .md and .json are supported.'),
};
}
@@ -333,7 +357,7 @@ const shareCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to share conversation.',
content: t('No chat client available to share conversation.'),
};
}
@@ -346,7 +370,7 @@ const shareCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
content: t('No conversation found to share.'),
};
}
@@ -362,14 +386,18 @@ const shareCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: `Conversation shared to ${filePath}`,
content: t('Conversation shared to {{filePath}}', {
filePath,
}),
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${errorMessage}`,
content: t('Error sharing conversation: {{error}}', {
error: errorMessage,
}),
};
}
},
@@ -377,7 +405,9 @@ const shareCommand: SlashCommand = {
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history.',
get description() {
return t('Manage conversation history.');
},
kind: CommandKind.BUILT_IN,
subCommands: [
listCommand,

View File

@@ -7,21 +7,24 @@
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const clearCommand: SlashCommand = {
name: 'clear',
description: 'clear the screen and conversation history',
get description() {
return t('clear the screen and conversation history');
},
kind: CommandKind.BUILT_IN,
action: async (context, _args) => {
const geminiClient = context.services.config?.getGeminiClient();
if (geminiClient) {
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
context.ui.setDebugMessage(t('Clearing terminal and resetting chat.'));
// If resetChat fails, the exception will propagate and halt the command,
// which is the correct behavior to signal a failure to the user.
await geminiClient.resetChat();
} else {
context.ui.setDebugMessage('Clearing terminal.');
context.ui.setDebugMessage(t('Clearing terminal.'));
}
uiTelemetryService.setLastPromptTokenCount(0);

View File

@@ -8,11 +8,14 @@ import type { HistoryItemCompression } from '../types.js';
import { MessageType } from '../types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const compressCommand: SlashCommand = {
name: 'compress',
altNames: ['summarize'],
description: 'Compresses the context by replacing it with a summary.',
get description() {
return t('Compresses the context by replacing it with a summary.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const { ui } = context;
@@ -20,7 +23,7 @@ export const compressCommand: SlashCommand = {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Already compressing, wait for previous request to complete',
text: t('Already compressing, wait for previous request to complete'),
},
Date.now(),
);
@@ -60,7 +63,7 @@ export const compressCommand: SlashCommand = {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Failed to compress chat history.',
text: t('Failed to compress chat history.'),
},
Date.now(),
);
@@ -69,9 +72,9 @@ export const compressCommand: SlashCommand = {
ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to compress chat history: ${
e instanceof Error ? e.message : String(e)
}`,
text: t('Failed to compress chat history: {{error}}', {
error: e instanceof Error ? e.message : String(e),
}),
},
Date.now(),
);

View File

@@ -7,10 +7,13 @@
import { copyToClipboard } from '../utils/commandUtils.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const copyCommand: SlashCommand = {
name: 'copy',
description: 'Copy the last result or code snippet to clipboard',
get description() {
return t('Copy the last result or code snippet to clipboard');
},
kind: CommandKind.BUILT_IN,
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
const chat = await context.services.config?.getGeminiClient()?.getChat();

View File

@@ -10,6 +10,7 @@ import { MessageType } from '../types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
export function expandHomeDir(p: string): string {
if (!p) {
@@ -27,13 +28,18 @@ export function expandHomeDir(p: string): string {
export const directoryCommand: SlashCommand = {
name: 'directory',
altNames: ['dir'],
description: 'Manage workspace directories',
get description() {
return t('Manage workspace directories');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'add',
description:
get description() {
return t(
'Add directories to the workspace. Use comma to separate multiple paths',
);
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => {
const {
@@ -46,7 +52,7 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
text: t('Configuration is not available.'),
},
Date.now(),
);
@@ -63,7 +69,7 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.ERROR,
text: 'Please provide at least one path to add.',
text: t('Please provide at least one path to add.'),
},
Date.now(),
);
@@ -74,8 +80,9 @@ export const directoryCommand: SlashCommand = {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
content: t(
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
),
};
}
@@ -88,7 +95,12 @@ export const directoryCommand: SlashCommand = {
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
errors.push(
t("Error adding '{{path}}': {{error}}", {
path: pathToAdd.trim(),
error: error.message,
}),
);
}
}
@@ -117,12 +129,21 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
text: t(
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
{
directories: added.join('\n- '),
},
),
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
errors.push(
t('Error refreshing memory: {{error}}', {
error: (error as Error).message,
}),
);
}
if (added.length > 0) {
@@ -133,7 +154,9 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.INFO,
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
text: t('Successfully added directories:\n- {{directories}}', {
directories: added.join('\n- '),
}),
},
Date.now(),
);
@@ -150,7 +173,9 @@ export const directoryCommand: SlashCommand = {
},
{
name: 'show',
description: 'Show all directories in the workspace',
get description() {
return t('Show all directories in the workspace');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
const {
@@ -161,7 +186,7 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
text: t('Configuration is not available.'),
},
Date.now(),
);
@@ -173,7 +198,9 @@ export const directoryCommand: SlashCommand = {
addItem(
{
type: MessageType.INFO,
text: `Current workspace directories:\n${directoryList}`,
text: t('Current workspace directories:\n{{directories}}', {
directories: directoryList,
}),
},
Date.now(),
);

View File

@@ -12,19 +12,28 @@ import {
CommandKind,
} from './types.js';
import { MessageType } from '../types.js';
import { t, getCurrentLanguage } from '../../i18n/index.js';
export const docsCommand: SlashCommand = {
name: 'docs',
description: 'open full Qwen Code documentation in your browser',
get description() {
return t('open full Qwen Code documentation in your browser');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en';
const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`;
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
text: t(
'Please open the following URL in your browser to view the documentation:\n{{url}}',
{
url: docsUrl,
},
),
},
Date.now(),
);
@@ -32,7 +41,9 @@ export const docsCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Opening documentation in your browser: ${docsUrl}`,
text: t('Opening documentation in your browser: {{url}}', {
url: docsUrl,
}),
},
Date.now(),
);

View File

@@ -9,10 +9,13 @@ import {
type OpenDialogActionReturn,
type SlashCommand,
} from './types.js';
import { t } from '../../i18n/index.js';
export const editorCommand: SlashCommand = {
name: 'editor',
description: 'set external editor preference',
get description() {
return t('set external editor preference');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -19,6 +19,7 @@ import {
type SlashCommand,
CommandKind,
} from './types.js';
import { t } from '../../i18n/index.js';
async function listAction(context: CommandContext) {
context.ui.addItem(
@@ -131,14 +132,18 @@ async function updateAction(context: CommandContext, args: string) {
const listExtensionsCommand: SlashCommand = {
name: 'list',
description: 'List active extensions',
get description() {
return t('List active extensions');
},
kind: CommandKind.BUILT_IN,
action: listAction,
};
const updateExtensionsCommand: SlashCommand = {
name: 'update',
description: 'Update extensions. Usage: update <extension-names>|--all',
get description() {
return t('Update extensions. Usage: update <extension-names>|--all');
},
kind: CommandKind.BUILT_IN,
action: updateAction,
completion: async (context, partialArg) => {
@@ -158,7 +163,9 @@ const updateExtensionsCommand: SlashCommand = {
export const extensionsCommand: SlashCommand = {
name: 'extensions',
description: 'Manage extensions',
get description() {
return t('Manage extensions');
},
kind: CommandKind.BUILT_IN,
subCommands: [listExtensionsCommand, updateExtensionsCommand],
action: (context, args) =>

View File

@@ -7,12 +7,15 @@
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemHelp } from '../types.js';
import { t } from '../../i18n/index.js';
export const helpCommand: SlashCommand = {
name: 'help',
altNames: ['?'],
kind: CommandKind.BUILT_IN,
description: 'for help on Qwen Code',
get description() {
return t('for help on Qwen Code');
},
action: async (context) => {
const helpItem: Omit<HistoryItemHelp, 'id'> = {
type: MessageType.HELP,

View File

@@ -26,6 +26,7 @@ import type {
} from './types.js';
import { CommandKind } from './types.js';
import { SettingScope } from '../../config/settings.js';
import { t } from '../../i18n/index.js';
function getIdeStatusMessage(ideClient: IdeClient): {
messageType: 'info' | 'error';
@@ -138,27 +139,35 @@ export const ideCommand = async (): Promise<SlashCommand> => {
if (!currentIDE) {
return {
name: 'ide',
description: 'manage IDE integration',
get description() {
return t('manage IDE integration');
},
kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn =>
({
type: 'message',
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.`,
content: t(
'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.',
),
}) as const,
};
}
const ideSlashCommand: SlashCommand = {
name: 'ide',
description: 'manage IDE integration',
get description() {
return t('manage IDE integration');
},
kind: CommandKind.BUILT_IN,
subCommands: [],
};
const statusCommand: SlashCommand = {
name: 'status',
description: 'check status of IDE integration',
get description() {
return t('check status of IDE integration');
},
kind: CommandKind.BUILT_IN,
action: async (): Promise<SlashCommandActionReturn> => {
const { messageType, content } =
@@ -173,7 +182,12 @@ export const ideCommand = async (): Promise<SlashCommand> => {
const installCommand: SlashCommand = {
name: 'install',
description: `install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`,
get description() {
const ideName = ideClient.getDetectedIdeDisplayName() ?? 'IDE';
return t('install required IDE companion for {{ideName}}', {
ideName,
});
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const installer = getIdeInstaller(currentIDE);
@@ -246,7 +260,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
const enableCommand: SlashCommand = {
name: 'enable',
description: 'enable IDE integration',
get description() {
return t('enable IDE integration');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(
@@ -268,7 +284,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
const disableCommand: SlashCommand = {
name: 'disable',
description: 'disable IDE integration',
get description() {
return t('disable IDE integration');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(

View File

@@ -15,10 +15,13 @@ import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
import { CommandKind } from './types.js';
import { Text } from 'ink';
import React from 'react';
import { t } from '../../i18n/index.js';
export const initCommand: SlashCommand = {
name: 'init',
description: 'Analyzes the project and creates a tailored QWEN.md file.',
get description() {
return t('Analyzes the project and creates a tailored QWEN.md file.');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -28,7 +31,7 @@ export const initCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
content: t('Configuration not available.'),
};
}
const targetDir = context.services.config.getTargetDir();

View File

@@ -0,0 +1,458 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
CommandContext,
SlashCommandActionReturn,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { SettingScope } from '../../config/settings.js';
import {
setLanguageAsync,
getCurrentLanguage,
type SupportedLanguage,
t,
} from '../../i18n/index.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core';
const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md';
/**
* Generates the LLM output language rule template based on the language name.
*/
function generateLlmOutputLanguageRule(language: string): string {
return `# ⚠️ CRITICAL: ${language} Output Language Rule - HIGHEST PRIORITY ⚠️
## 🚨 MANDATORY RULE - NO EXCEPTIONS 🚨
**YOU MUST RESPOND IN ${language.toUpperCase()} FOR EVERY SINGLE OUTPUT, REGARDLESS OF THE USER'S INPUT LANGUAGE.**
This is a **NON-NEGOTIABLE** requirement. Even if the user writes in English, says "hi", asks a simple question, or explicitly requests another language, **YOU MUST ALWAYS RESPOND IN ${language.toUpperCase()}.**
## What Must Be in ${language}
**EVERYTHING** you output: conversation replies, tool call descriptions, success/error messages, generated file content (comments, documentation), and all explanatory text.
**Tool outputs**: All descriptive text from \`read_file\`, \`write_file\`, \`codebase_search\`, \`run_terminal_cmd\`, \`todo_write\`, \`web_search\`, etc. MUST be in ${language}.
## Examples
### ✅ CORRECT:
- User says "hi" → Respond in ${language} (e.g., "Bonjour" if ${language} is French)
- Tool result → "已成功读取文件 config.json" (if ${language} is Chinese)
- Error → "无法找到指定的文件" (if ${language} is Chinese)
### ❌ WRONG:
- User says "hi" → "Hello" in English
- Tool result → "Successfully read file" in English
- Error → "File not found" in English
## Notes
- Code elements (variable/function names, syntax) can remain in English
- Comments, documentation, and all other text MUST be in ${language}
**THIS RULE IS ACTIVE NOW. ALL OUTPUTS MUST BE IN ${language.toUpperCase()}. NO EXCEPTIONS.**
`;
}
/**
* Gets the path to the LLM output language rule file.
*/
function getLlmOutputLanguageRulePath(): string {
return path.join(
Storage.getGlobalQwenDir(),
LLM_OUTPUT_LANGUAGE_RULE_FILENAME,
);
}
/**
* Gets the current LLM output language from the rule file if it exists.
*/
function getCurrentLlmOutputLanguage(): string | null {
const filePath = getLlmOutputLanguageRulePath();
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);
if (match) {
return match[1];
}
} catch {
// Ignore errors
}
}
return null;
}
/**
* Sets the UI language and persists it to settings.
*/
async function setUiLanguage(
context: CommandContext,
lang: SupportedLanguage,
): Promise<MessageActionReturn> {
const { services } = context;
const { settings } = services;
if (!services.config) {
return {
type: 'message',
messageType: 'error',
content: t('Configuration not available.'),
};
}
// Set language in i18n system (async to support JS translation files)
await setLanguageAsync(lang);
// Persist to settings (user scope)
if (settings && typeof settings.setValue === 'function') {
try {
settings.setValue(SettingScope.User, 'general.language', lang);
} catch (error) {
console.warn('Failed to save language setting:', error);
}
}
// Reload commands to update their descriptions with the new language
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Record<SupportedLanguage, string> = {
zh: '中文zh-CN',
en: 'Englishen-US',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang],
}),
};
}
/**
* Generates the LLM output language rule file.
*/
function generateLlmOutputLanguageRuleFile(
language: string,
): Promise<MessageActionReturn> {
try {
const filePath = getLlmOutputLanguageRulePath();
const content = generateLlmOutputLanguageRule(language);
// Ensure directory exists
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
// Write file (overwrite if exists)
fs.writeFileSync(filePath, content, 'utf-8');
return Promise.resolve({
type: 'message',
messageType: 'info',
content: [
t('LLM output language rule file generated at {{path}}', {
path: filePath,
}),
'',
t('Please restart the application for the changes to take effect.'),
].join('\n'),
});
} catch (error) {
return Promise.resolve({
type: 'message',
messageType: 'error',
content: t(
'Failed to generate LLM output language rule file: {{error}}',
{
error: error instanceof Error ? error.message : String(error),
},
),
});
}
}
export const languageCommand: SlashCommand = {
name: 'language',
get description() {
return t('View or change the language setting');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
const { services } = context;
if (!services.config) {
return {
type: 'message',
messageType: 'error',
content: t('Configuration not available.'),
};
}
const trimmedArgs = args.trim();
// If no arguments, show current language settings and usage
if (!trimmedArgs) {
const currentUiLang = getCurrentLanguage();
const currentLlmLang = getCurrentLlmOutputLanguage();
const message = [
t('Current UI language: {{lang}}', { lang: currentUiLang }),
currentLlmLang
? t('Current LLM output language: {{lang}}', { lang: currentLlmLang })
: t('LLM output language not set'),
'',
t('Available subcommands:'),
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n');
return {
type: 'message',
messageType: 'info',
content: message,
};
}
// Parse subcommand
const parts = trimmedArgs.split(/\s+/);
const subcommand = parts[0].toLowerCase();
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US]
if (parts.length === 1) {
// Show UI language subcommand help
return {
type: 'message',
messageType: 'info',
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
),
].join('\n'),
};
}
const langArg = parts[1].toLowerCase();
let targetLang: SupportedLanguage | null = null;
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
};
}
return setUiLanguage(context, targetLang);
} else if (subcommand === 'output') {
// Handle /language output <language>
if (parts.length === 1) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set LLM output language'),
'',
t('Usage: /language output <language>'),
` ${t('Example: /language output 中文')}`,
].join('\n'),
};
}
// Join all parts after "output" as the language name
const language = parts.slice(1).join(' ');
return generateLlmOutputLanguageRuleFile(language);
} else {
// Backward compatibility: treat as UI language
const langArg = trimmedArgs.toLowerCase();
let targetLang: SupportedLanguage | null = null;
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
}
return setUiLanguage(context, targetLang);
}
},
subCommands: [
{
name: 'ui',
get description() {
return t('Set UI language');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
),
].join('\n'),
};
}
const langArg = trimmedArgs.toLowerCase();
let targetLang: SupportedLanguage | null = null;
if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') {
targetLang = 'en';
} else if (
langArg === 'zh' ||
langArg === 'chinese' ||
langArg === '中文' ||
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
};
}
return setUiLanguage(context, targetLang);
},
subCommands: [
{
name: 'zh-CN',
altNames: ['zh', 'chinese', '中文'],
get description() {
return t('Set UI language to Simplified Chinese (zh-CN)');
},
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, 'zh');
},
},
{
name: 'en-US',
altNames: ['en', 'english'],
get description() {
return t('Set UI language to English (en-US)');
},
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, 'en');
},
},
],
},
{
name: 'output',
get description() {
return t('Set LLM output language');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set LLM output language'),
'',
t('Usage: /language output <language>'),
` ${t('Example: /language output 中文')}`,
` ${t('Example: /language output English')}`,
` ${t('Example: /language output 日本語')}`,
].join('\n'),
};
}
return generateLlmOutputLanguageRuleFile(trimmedArgs);
},
},
],
};

View File

@@ -24,10 +24,13 @@ import {
} from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
import { t } from '../../i18n/index.js';
const authCommand: SlashCommand = {
name: 'auth',
description: 'Authenticate with an OAuth-enabled MCP server',
get description() {
return t('Authenticate with an OAuth-enabled MCP server');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -40,7 +43,7 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
content: t('Config not loaded.'),
};
}
@@ -56,14 +59,14 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: 'No MCP servers configured with OAuth authentication.',
content: t('No MCP servers configured with OAuth authentication.'),
};
}
return {
type: 'message',
messageType: 'info',
content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth <server-name> to authenticate.`,
content: `${t('MCP servers with OAuth authentication:')}\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\n${t('Use /mcp auth <server-name> to authenticate.')}`,
};
}
@@ -72,7 +75,7 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: `MCP server '${serverName}' not found.`,
content: t("MCP server '{{name}}' not found.", { name: serverName }),
};
}
@@ -89,7 +92,12 @@ const authCommand: SlashCommand = {
context.ui.addItem(
{
type: 'info',
text: `Starting OAuth authentication for MCP server '${serverName}'...`,
text: t(
"Starting OAuth authentication for MCP server '{{name}}'...",
{
name: serverName,
},
),
},
Date.now(),
);
@@ -111,7 +119,12 @@ const authCommand: SlashCommand = {
context.ui.addItem(
{
type: 'info',
text: `✅ Successfully authenticated with MCP server '${serverName}'!`,
text: t(
"Successfully authenticated and refreshed tools for '{{name}}'.",
{
name: serverName,
},
),
},
Date.now(),
);
@@ -122,7 +135,9 @@ const authCommand: SlashCommand = {
context.ui.addItem(
{
type: 'info',
text: `Re-discovering tools from '${serverName}'...`,
text: t("Re-discovering tools from '{{name}}'...", {
name: serverName,
}),
},
Date.now(),
);
@@ -140,13 +155,24 @@ const authCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
content: t(
"Successfully authenticated and refreshed tools for '{{name}}'.",
{
name: serverName,
},
),
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
content: t(
"Failed to authenticate with MCP server '{{name}}': {{error}}",
{
name: serverName,
error: getErrorMessage(error),
},
),
};
} finally {
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
@@ -165,7 +191,9 @@ const authCommand: SlashCommand = {
const listCommand: SlashCommand = {
name: 'list',
description: 'List configured MCP servers and tools',
get description() {
return t('List configured MCP servers and tools');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -176,7 +204,7 @@ const listCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
content: t('Config not loaded.'),
};
}
@@ -185,7 +213,7 @@ const listCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
content: t('Could not retrieve tool registry.'),
};
}
@@ -276,7 +304,9 @@ const listCommand: SlashCommand = {
const refreshCommand: SlashCommand = {
name: 'refresh',
description: 'Restarts MCP servers.',
get description() {
return t('Restarts MCP servers.');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -286,7 +316,7 @@ const refreshCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
content: t('Config not loaded.'),
};
}
@@ -295,14 +325,14 @@ const refreshCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
content: t('Could not retrieve tool registry.'),
};
}
context.ui.addItem(
{
type: 'info',
text: 'Restarting MCP servers...',
text: t('Restarting MCP servers...'),
},
Date.now(),
);
@@ -324,8 +354,11 @@ const refreshCommand: SlashCommand = {
export const mcpCommand: SlashCommand = {
name: 'mcp',
description:
get description() {
return t(
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
);
},
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, authCommand, refreshCommand],
// Default action when no subcommand is provided

View File

@@ -15,15 +15,20 @@ import fs from 'fs/promises';
import { MessageType } from '../types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
description: 'Commands for interacting with memory.',
get description() {
return t('Commands for interacting with memory.');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'show',
description: 'Show the current memory contents.',
get description() {
return t('Show the current memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const memoryContent = context.services.config?.getUserMemory() || '';
@@ -31,8 +36,8 @@ export const memoryCommand: SlashCommand = {
const messageContent =
memoryContent.length > 0
? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
: 'Memory is currently empty.';
? `${t('Current memory content from {{count}} file(s):', { count: String(fileCount) })}\n\n---\n${memoryContent}\n---`
: t('Memory is currently empty.');
context.ui.addItem(
{
@@ -45,7 +50,9 @@ export const memoryCommand: SlashCommand = {
subCommands: [
{
name: '--project',
description: 'Show project-level memory contents.',
get description() {
return t('Show project-level memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
@@ -57,8 +64,14 @@ export const memoryCommand: SlashCommand = {
const messageContent =
memoryContent.trim().length > 0
? `Project memory content from ${projectMemoryPath}:\n\n---\n${memoryContent}\n---`
: 'Project memory is currently empty.';
? t(
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
{
path: projectMemoryPath,
content: memoryContent,
},
)
: t('Project memory is currently empty.');
context.ui.addItem(
{
@@ -71,7 +84,9 @@ export const memoryCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Project memory file not found or is currently empty.',
text: t(
'Project memory file not found or is currently empty.',
),
},
Date.now(),
);
@@ -80,7 +95,9 @@ export const memoryCommand: SlashCommand = {
},
{
name: '--global',
description: 'Show global memory contents.',
get description() {
return t('Show global memory contents.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
@@ -96,8 +113,10 @@ export const memoryCommand: SlashCommand = {
const messageContent =
globalMemoryContent.trim().length > 0
? `Global memory content:\n\n---\n${globalMemoryContent}\n---`
: 'Global memory is currently empty.';
? t('Global memory content:\n\n---\n{{content}}\n---', {
content: globalMemoryContent,
})
: t('Global memory is currently empty.');
context.ui.addItem(
{
@@ -110,7 +129,9 @@ export const memoryCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Global memory file not found or is currently empty.',
text: t(
'Global memory file not found or is currently empty.',
),
},
Date.now(),
);
@@ -121,16 +142,20 @@ export const memoryCommand: SlashCommand = {
},
{
name: 'add',
description:
get description() {
return t(
'Add content to the memory. Use --global for global memory or --project for project memory.',
);
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
}
@@ -150,8 +175,9 @@ export const memoryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
} else {
// No scope specified, will be handled by the tool
@@ -162,8 +188,9 @@ export const memoryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
}
@@ -171,7 +198,10 @@ export const memoryCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory ${scopeText}: "${fact}"`,
text: t('Attempting to save to memory {{scope}}: "{{fact}}"', {
scope: scopeText,
fact,
}),
},
Date.now(),
);
@@ -185,21 +215,25 @@ export const memoryCommand: SlashCommand = {
subCommands: [
{
name: '--project',
description: 'Add content to project-level memory.',
get description() {
return t('Add content to project-level memory.');
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add --project <text to remember>',
content: t('Usage: /memory add --project <text to remember>'),
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to project memory: "${args.trim()}"`,
text: t('Attempting to save to project memory: "{{text}}"', {
text: args.trim(),
}),
},
Date.now(),
);
@@ -213,21 +247,25 @@ export const memoryCommand: SlashCommand = {
},
{
name: '--global',
description: 'Add content to global memory.',
get description() {
return t('Add content to global memory.');
},
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add --global <text to remember>',
content: t('Usage: /memory add --global <text to remember>'),
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to global memory: "${args.trim()}"`,
text: t('Attempting to save to global memory: "{{text}}"', {
text: args.trim(),
}),
},
Date.now(),
);
@@ -243,13 +281,15 @@ export const memoryCommand: SlashCommand = {
},
{
name: 'refresh',
description: 'Refresh the memory from the source.',
get description() {
return t('Refresh the memory from the source.');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Refreshing memory from source files...',
text: t('Refreshing memory from source files...'),
},
Date.now(),
);

View File

@@ -12,10 +12,13 @@ import type {
} from './types.js';
import { CommandKind } from './types.js';
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
import { t } from '../../i18n/index.js';
export const modelCommand: SlashCommand = {
name: 'model',
description: 'Switch the model for this session',
get description() {
return t('Switch the model for this session');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
@@ -36,7 +39,7 @@ export const modelCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Content generator configuration not available.',
content: t('Content generator configuration not available.'),
};
}
@@ -45,7 +48,7 @@ export const modelCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Authentication type not available.',
content: t('Authentication type not available.'),
};
}
@@ -55,7 +58,12 @@ export const modelCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: `No models available for the current authentication type (${authType}).`,
content: t(
'No models available for the current authentication type ({{authType}}).',
{
authType,
},
),
};
}

View File

@@ -6,10 +6,13 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const permissionsCommand: SlashCommand = {
name: 'permissions',
description: 'Manage folder trust settings',
get description() {
return t('Manage folder trust settings');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -6,10 +6,13 @@
import { formatDuration } from '../utils/formatters.js';
import { CommandKind, type SlashCommand } from './types.js';
import { t } from '../../i18n/index.js';
export const quitConfirmCommand: SlashCommand = {
name: 'quit-confirm',
description: 'Show quit confirmation dialog',
get description() {
return t('Show quit confirmation dialog');
},
kind: CommandKind.BUILT_IN,
action: (context) => {
const now = Date.now();
@@ -37,7 +40,9 @@ export const quitConfirmCommand: SlashCommand = {
export const quitCommand: SlashCommand = {
name: 'quit',
altNames: ['exit'],
description: 'exit the cli',
get description() {
return t('exit the cli');
},
kind: CommandKind.BUILT_IN,
action: (context) => {
const now = Date.now();

View File

@@ -6,10 +6,13 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const settingsCommand: SlashCommand = {
name: 'settings',
description: 'View and edit Qwen Code settings',
get description() {
return t('View and edit Qwen Code settings');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -20,6 +20,7 @@ import {
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { t } from '../../i18n/index.js';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-dispatch/gemini-dispatch.yml',
@@ -91,7 +92,9 @@ export async function updateGitignore(gitRepoRoot: string): Promise<void> {
export const setupGithubCommand: SlashCommand = {
name: 'setup-github',
description: 'Set up GitHub Actions',
get description() {
return t('Set up GitHub Actions');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,

View File

@@ -12,11 +12,14 @@ import {
type SlashCommand,
CommandKind,
} from './types.js';
import { t } from '../../i18n/index.js';
export const statsCommand: SlashCommand = {
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
get description() {
return t('check session stats. Usage: /stats [model|tools]');
},
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
const now = new Date();
@@ -25,7 +28,7 @@ export const statsCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Session start time is unavailable, cannot calculate stats.',
text: t('Session start time is unavailable, cannot calculate stats.'),
},
Date.now(),
);
@@ -43,7 +46,9 @@ export const statsCommand: SlashCommand = {
subCommands: [
{
name: 'model',
description: 'Show model-specific usage statistics.',
get description() {
return t('Show model-specific usage statistics.');
},
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
context.ui.addItem(
@@ -56,7 +61,9 @@ export const statsCommand: SlashCommand = {
},
{
name: 'tools',
description: 'Show tool-specific usage statistics.',
get description() {
return t('Show tool-specific usage statistics.');
},
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => {
context.ui.addItem(

View File

@@ -13,11 +13,15 @@ import {
} from './types.js';
import { getProjectSummaryPrompt } from '@qwen-code/qwen-code-core';
import type { HistoryItemSummary } from '../types.js';
import { t } from '../../i18n/index.js';
export const summaryCommand: SlashCommand = {
name: 'summary',
description:
get description() {
return t(
'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md',
);
},
kind: CommandKind.BUILT_IN,
action: async (context): Promise<SlashCommandActionReturn> => {
const { config } = context.services;
@@ -26,7 +30,7 @@ export const summaryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
content: t('Config not loaded.'),
};
}
@@ -35,7 +39,7 @@ export const summaryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to generate summary.',
content: t('No chat client available to generate summary.'),
};
}
@@ -44,15 +48,18 @@ export const summaryCommand: SlashCommand = {
ui.addItem(
{
type: 'error' as const,
text: 'Already generating summary, wait for previous request to complete',
text: t(
'Already generating summary, wait for previous request to complete',
),
},
Date.now(),
);
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Already generating summary, wait for previous request to complete',
),
};
}
@@ -65,7 +72,7 @@ export const summaryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to summarize.',
content: t('No conversation found to summarize.'),
};
}
@@ -171,9 +178,12 @@ export const summaryCommand: SlashCommand = {
ui.addItem(
{
type: 'error' as const,
text: `Failed to generate project context summary: ${
error instanceof Error ? error.message : String(error)
}`,
text: `${t(
'Failed to generate project context summary: {{error}}',
{
error: error instanceof Error ? error.message : String(error),
},
)}`,
},
Date.now(),
);
@@ -181,9 +191,9 @@ export const summaryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: `Failed to generate project context summary: ${
error instanceof Error ? error.message : String(error)
}`,
content: t('Failed to generate project context summary: {{error}}', {
error: error instanceof Error ? error.message : String(error),
}),
};
}
},

View File

@@ -7,6 +7,7 @@
import type { MessageActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { terminalSetup } from '../utils/terminalSetup.js';
import { t } from '../../i18n/index.js';
/**
* Command to configure terminal keybindings for multiline input support.
@@ -16,8 +17,11 @@ import { terminalSetup } from '../utils/terminalSetup.js';
*/
export const terminalSetupCommand: SlashCommand = {
name: 'terminal-setup',
description:
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)',
get description() {
return t(
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
);
},
kind: CommandKind.BUILT_IN,
action: async (): Promise<MessageActionReturn> => {
@@ -27,7 +31,8 @@ export const terminalSetupCommand: SlashCommand = {
let content = result.message;
if (result.requiresRestart) {
content +=
'\n\nPlease restart your terminal for the changes to take effect.';
'\n\n' +
t('Please restart your terminal for the changes to take effect.');
}
return {
@@ -38,7 +43,9 @@ export const terminalSetupCommand: SlashCommand = {
} catch (error) {
return {
type: 'message',
content: `Failed to configure terminal: ${error}`,
content: t('Failed to configure terminal: {{error}}', {
error: String(error),
}),
messageType: 'error',
};
}

View File

@@ -6,10 +6,13 @@
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const themeCommand: SlashCommand = {
name: 'theme',
description: 'change the theme',
get description() {
return t('change the theme');
},
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -10,10 +10,13 @@ import {
CommandKind,
} from './types.js';
import { MessageType, type HistoryItemToolsList } from '../types.js';
import { t } from '../../i18n/index.js';
export const toolsCommand: SlashCommand = {
name: 'tools',
description: 'list available Qwen Code tools. Usage: /tools [desc]',
get description() {
return t('list available Qwen Code tools. Usage: /tools [desc]');
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();
@@ -29,7 +32,7 @@ export const toolsCommand: SlashCommand = {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Could not retrieve tool registry.',
text: t('Could not retrieve tool registry.'),
},
Date.now(),
);

View File

@@ -129,7 +129,8 @@ export interface OpenDialogActionReturn {
| 'model'
| 'subagent_create'
| 'subagent_list'
| 'permissions';
| 'permissions'
| 'approval-mode';
}
/**

View File

@@ -6,10 +6,13 @@
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const vimCommand: SlashCommand = {
name: 'vim',
description: 'toggle vim mode on/off',
get description() {
return t('toggle vim mode on/off');
},
kind: CommandKind.BUILT_IN,
action: async (context, _args) => {
const newVimState = await context.ui.toggleVimEnabled();

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