mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-07 17:39:17 +00:00
Compare commits
10 Commits
release/v0
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9af74816a | ||
|
|
9cfea73207 | ||
|
|
87b1ffe017 | ||
|
|
83fc321e15 | ||
|
|
48b77541c3 | ||
|
|
f2439f8d53 | ||
|
|
fb6d0b43fa | ||
|
|
627283d357 | ||
|
|
640f30655d | ||
|
|
9e5387f159 |
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -224,5 +224,4 @@ jobs:
|
|||||||
run: |-
|
run: |-
|
||||||
gh issue create \
|
gh issue create \
|
||||||
--title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
|
--title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
|
||||||
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}" \
|
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||||
--label "kind/bug,release-failure"
|
|
||||||
|
|||||||
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
@@ -73,7 +73,16 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Launch CLI Non-Interactive",
|
"name": "Launch CLI Non-Interactive",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"],
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"start",
|
||||||
|
"--",
|
||||||
|
"-p",
|
||||||
|
"${input:prompt}",
|
||||||
|
"-y",
|
||||||
|
"--output-format",
|
||||||
|
"stream-json"
|
||||||
|
],
|
||||||
"skipFiles": ["<node_internals>/**"],
|
"skipFiles": ["<node_internals>/**"],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ Slash commands provide meta-level control over the CLI itself.
|
|||||||
- **`/init`**
|
- **`/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.
|
- **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
|
### Custom Commands
|
||||||
|
|
||||||
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.
|
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.
|
||||||
|
|||||||
@@ -548,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.
|
- The prompt is processed within the interactive session, not before it.
|
||||||
- Cannot be used when piping input from stdin.
|
- Cannot be used when piping input from stdin.
|
||||||
- Example: `qwen -i "explain this code"`
|
- 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.
|
- **Description:** Specifies the format of the CLI output for non-interactive mode.
|
||||||
- **Values:**
|
- **Values:**
|
||||||
- `text`: (Default) The standard human-readable output.
|
- `text`: (Default) The standard human-readable output.
|
||||||
- `json`: A machine-readable JSON output.
|
- `json`: A machine-readable JSON output emitted at the end of execution.
|
||||||
- **Note:** For structured output and scripting, use the `--output-format json` flag.
|
- `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`**):
|
- **`--sandbox`** (**`-s`**):
|
||||||
- Enables sandbox mode for this session.
|
- Enables sandbox mode for this session.
|
||||||
- **`--sandbox-image`**:
|
- **`--sandbox-image`**:
|
||||||
|
|||||||
71
docs/cli/language.md
Normal file
71
docs/cli/language.md
Normal 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
|
||||||
@@ -13,8 +13,9 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
|||||||
- [Output Formats](#output-formats)
|
- [Output Formats](#output-formats)
|
||||||
- [Text Output (Default)](#text-output-default)
|
- [Text Output (Default)](#text-output-default)
|
||||||
- [JSON Output](#json-output)
|
- [JSON Output](#json-output)
|
||||||
- [Response Schema](#response-schema)
|
|
||||||
- [Example Usage](#example-usage)
|
- [Example Usage](#example-usage)
|
||||||
|
- [Stream-JSON Output](#stream-json-output)
|
||||||
|
- [Input Format](#input-format)
|
||||||
- [File Redirection](#file-redirection)
|
- [File Redirection](#file-redirection)
|
||||||
- [Configuration Options](#configuration-options)
|
- [Configuration Options](#configuration-options)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
@@ -22,7 +23,7 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
|||||||
- [Generate commit messages](#generate-commit-messages)
|
- [Generate commit messages](#generate-commit-messages)
|
||||||
- [API documentation](#api-documentation)
|
- [API documentation](#api-documentation)
|
||||||
- [Batch code analysis](#batch-code-analysis)
|
- [Batch code analysis](#batch-code-analysis)
|
||||||
- [Code review](#code-review-1)
|
- [PR code review](#pr-code-review)
|
||||||
- [Log analysis](#log-analysis)
|
- [Log analysis](#log-analysis)
|
||||||
- [Release notes generation](#release-notes-generation)
|
- [Release notes generation](#release-notes-generation)
|
||||||
- [Model and tool usage tracking](#model-and-tool-usage-tracking)
|
- [Model and tool usage tracking](#model-and-tool-usage-tracking)
|
||||||
@@ -66,6 +67,8 @@ cat README.md | qwen --prompt "Summarize this documentation"
|
|||||||
|
|
||||||
## Output Formats
|
## Output Formats
|
||||||
|
|
||||||
|
Qwen Code supports multiple output formats for different use cases:
|
||||||
|
|
||||||
### Text Output (Default)
|
### Text Output (Default)
|
||||||
|
|
||||||
Standard human-readable output:
|
Standard human-readable output:
|
||||||
@@ -82,56 +85,9 @@ The capital of France is Paris.
|
|||||||
|
|
||||||
### JSON Output
|
### JSON Output
|
||||||
|
|
||||||
Returns structured data including response, statistics, and metadata. This
|
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.
|
||||||
format is ideal for programmatic processing and automation scripts.
|
|
||||||
|
|
||||||
#### Response Schema
|
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).
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Example Usage
|
#### 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
|
qwen -p "What is the capital of France?" --output-format json
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Output (at end of execution):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
[
|
||||||
"response": "The capital of France is Paris.",
|
{
|
||||||
"stats": {
|
"type": "system",
|
||||||
"models": {
|
"subtype": "session_start",
|
||||||
"qwen3-coder-plus": {
|
"uuid": "...",
|
||||||
"api": {
|
"session_id": "...",
|
||||||
"totalRequests": 2,
|
"model": "qwen3-coder-plus",
|
||||||
"totalErrors": 0,
|
...
|
||||||
"totalLatencyMs": 5053
|
},
|
||||||
},
|
{
|
||||||
"tokens": {
|
"type": "assistant",
|
||||||
"prompt": 24939,
|
"uuid": "...",
|
||||||
"candidates": 20,
|
"session_id": "...",
|
||||||
"total": 25113,
|
"message": {
|
||||||
"cached": 21263,
|
"id": "...",
|
||||||
"thoughts": 154,
|
"type": "message",
|
||||||
"tool": 0
|
"role": "assistant",
|
||||||
|
"model": "qwen3-coder-plus",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "The capital of France is Paris."
|
||||||
}
|
}
|
||||||
}
|
],
|
||||||
|
"usage": {...}
|
||||||
},
|
},
|
||||||
"tools": {
|
"parent_tool_use_id": null
|
||||||
"totalCalls": 1,
|
},
|
||||||
"totalSuccess": 1,
|
{
|
||||||
"totalFail": 0,
|
"type": "result",
|
||||||
"totalDurationMs": 1881,
|
"subtype": "success",
|
||||||
"totalDecisions": {
|
"uuid": "...",
|
||||||
"accept": 0,
|
"session_id": "...",
|
||||||
"reject": 0,
|
"is_error": false,
|
||||||
"modify": 0,
|
"duration_ms": 1234,
|
||||||
"auto_accept": 1
|
"result": "The capital of France is Paris.",
|
||||||
},
|
"usage": {...}
|
||||||
"byName": {
|
|
||||||
"google_web_search": {
|
|
||||||
"count": 1,
|
|
||||||
"success": 1,
|
|
||||||
"fail": 0,
|
|
||||||
"durationMs": 1881,
|
|
||||||
"decisions": {
|
|
||||||
"accept": 0,
|
|
||||||
"reject": 0,
|
|
||||||
"modify": 0,
|
|
||||||
"auto_accept": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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
|
### File Redirection
|
||||||
|
|
||||||
Save output to files or pipe to other commands:
|
Save output to files or pipe to other commands:
|
||||||
@@ -212,48 +186,53 @@ qwen -p "Add more details" >> docker-explanation.txt
|
|||||||
qwen -p "What is Kubernetes?" --output-format json | jq '.response'
|
qwen -p "What is Kubernetes?" --output-format json | jq '.response'
|
||||||
qwen -p "Explain microservices" | wc -w
|
qwen -p "Explain microservices" | wc -w
|
||||||
qwen -p "List programming languages" | grep -i "python"
|
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
|
## Configuration Options
|
||||||
|
|
||||||
Key command-line options for headless usage:
|
Key command-line options for headless usage:
|
||||||
|
|
||||||
| Option | Description | Example |
|
| Option | Description | Example |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------ |
|
| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||||
| `--output-format` | Specify output format (text, json) | `qwen -p "query" --output-format json` |
|
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||||
| `--model`, `-m` | Specify the Qwen model | `qwen -p "query" -m qwen3-coder-plus` |
|
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||||
|
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||||
|
|
||||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md).
|
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md).
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
#### Code review
|
### Code review
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt
|
cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Generate commit messages
|
### Generate commit messages
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json)
|
result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json)
|
||||||
echo "$result" | jq -r '.response'
|
echo "$result" | jq -r '.response'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### API documentation
|
### API documentation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json)
|
result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json)
|
||||||
echo "$result" | jq -r '.response' > openapi.json
|
echo "$result" | jq -r '.response' > openapi.json
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Batch code analysis
|
### Batch code analysis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
for file in src/*.py; do
|
for file in src/*.py; do
|
||||||
@@ -264,20 +243,20 @@ for file in src/*.py; do
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Code review
|
### PR code review
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json)
|
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
|
echo "$result" | jq -r '.response' > pr-review.json
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Log analysis
|
### Log analysis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt
|
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
|
```bash
|
||||||
result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json)
|
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
|
echo "$response" >> CHANGELOG.md
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Model and tool usage tracking
|
### Model and tool usage tracking
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(qwen -p "Explain this database schema" --include-directories db --output-format json)
|
result=$(qwen -p "Explain this database schema" --include-directories db --output-format json)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe('JSON output', () => {
|
|||||||
await rig.cleanup();
|
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(
|
const result = await rig.run(
|
||||||
'What is the capital of France?',
|
'What is the capital of France?',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
@@ -27,15 +27,34 @@ describe('JSON output', () => {
|
|||||||
);
|
);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveProperty('response');
|
// The output should be an array of messages
|
||||||
expect(typeof parsed.response).toBe('string');
|
expect(Array.isArray(parsed)).toBe(true);
|
||||||
expect(parsed.response.toLowerCase()).toContain('paris');
|
expect(parsed.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(parsed).toHaveProperty('stats');
|
// Find the result message (should be the last message)
|
||||||
expect(typeof parsed.stats).toBe('object');
|
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 () => {
|
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';
|
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||||
await rig.setup('json-output-auth-mismatch', {
|
await rig.setup('json-output-auth-mismatch', {
|
||||||
settings: {
|
settings: {
|
||||||
@@ -50,38 +69,242 @@ describe('JSON output', () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
thrown = e as Error;
|
thrown = e as Error;
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env['OPENAI_API_KEY'];
|
process.env['OPENAI_API_KEY'] = originalOpenaiApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(thrown).toBeDefined();
|
expect(thrown).toBeDefined();
|
||||||
const message = (thrown as Error).message;
|
const message = (thrown as Error).message;
|
||||||
|
|
||||||
// Use a regex to find the first complete JSON object in the string
|
// The error JSON is written to stdout as a CLIResultMessageError
|
||||||
const jsonMatch = message.match(/{[\s\S]*}/);
|
// Extract stdout from the error message
|
||||||
|
const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/);
|
||||||
// Fail if no JSON-like text was found
|
|
||||||
expect(
|
expect(
|
||||||
jsonMatch,
|
stdoutMatch,
|
||||||
'Expected to find a JSON object in the error output',
|
'Expected to find stdout in the error message',
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|
||||||
let payload;
|
const stdout = stdoutMatch![1];
|
||||||
|
let parsed: unknown[];
|
||||||
try {
|
try {
|
||||||
// Parse the matched JSON string
|
// Parse the JSON array from stdout
|
||||||
payload = JSON.parse(jsonMatch![0]);
|
parsed = JSON.parse(stdout);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('Failed to parse the following JSON:', jsonMatch![0]);
|
console.error('Failed to parse the following JSON:', stdout);
|
||||||
throw new Error(
|
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();
|
// The output should be an array of messages
|
||||||
expect(payload.error.type).toBe('Error');
|
expect(Array.isArray(parsed)).toBe(true);
|
||||||
expect(payload.error.code).toBe(1);
|
expect(parsed.length).toBeGreaterThan(0);
|
||||||
expect(payload.error.message).toContain(
|
|
||||||
|
// 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',
|
'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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -340,7 +340,8 @@ export class TestRig {
|
|||||||
// as it would corrupt the JSON
|
// as it would corrupt the JSON
|
||||||
const isJsonOutput =
|
const isJsonOutput =
|
||||||
commandArgs.includes('--output-format') &&
|
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 we have stderr output and it's not a JSON test, include that also
|
||||||
if (stderr && !isJsonOutput) {
|
if (stderr && !isJsonOutput) {
|
||||||
@@ -349,7 +350,23 @@ export class TestRig {
|
|||||||
|
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
// 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}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
@@ -16024,7 +16024,7 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.16.0",
|
"@google/genai": "1.16.0",
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
@@ -16139,7 +16139,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@qwen-code/qwen-code-core",
|
"name": "@qwen-code/qwen-code-core",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.16.0",
|
"@google/genai": "1.16.0",
|
||||||
@@ -16278,7 +16278,7 @@
|
|||||||
},
|
},
|
||||||
"packages/test-utils": {
|
"packages/test-utils": {
|
||||||
"name": "@qwen-code/qwen-code-test-utils",
|
"name": "@qwen-code/qwen-code-test-utils",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -16290,7 +16290,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode-ide-companion": {
|
"packages/vscode-ide-companion": {
|
||||||
"name": "qwen-code-vscode-ide-companion",
|
"name": "qwen-code-vscode-ide-companion",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"license": "LICENSE",
|
"license": "LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.3"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env node scripts/start.js",
|
"start": "cross-env node scripts/start.js",
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
"lint:all": "node scripts/lint.js",
|
"lint:all": "node scripts/lint.js",
|
||||||
"format": "prettier --experimental-cli --write .",
|
"format": "prettier --experimental-cli --write .",
|
||||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
"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",
|
"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": "husky && npm run bundle",
|
||||||
"prepare:package": "node scripts/prepare-package.js",
|
"prepare:package": "node scripts/prepare-package.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@qwen-code/qwen-code",
|
"name": "@qwen-code/qwen-code",
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"description": "Qwen Code",
|
"description": "Qwen Code",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -8,9 +8,16 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"qwen": "dist/index.js"
|
"qwen": "dist/index.js"
|
||||||
},
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build_package.js",
|
"build": "node ../../scripts/build_package.js",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
@@ -19,13 +26,14 @@
|
|||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:ci": "vitest run",
|
"test:ci": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"check-i18n": "tsx ../../scripts/check-i18n.ts"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"config": {
|
"config": {
|
||||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.3"
|
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "1.16.0",
|
"@google/genai": "1.16.0",
|
||||||
|
|||||||
@@ -392,6 +392,49 @@ describe('parseArguments', () => {
|
|||||||
mockConsoleError.mockRestore();
|
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 () => {
|
it('should allow --approval-mode without --yolo', async () => {
|
||||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
||||||
const argv = await parseArguments({} as Settings);
|
const argv = await parseArguments({} as Settings);
|
||||||
@@ -473,6 +516,34 @@ describe('loadCliConfig', () => {
|
|||||||
vi.restoreAllMocks();
|
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 () => {
|
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
|
||||||
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
||||||
const argv = await parseArguments({} as Settings);
|
const argv = await parseArguments({} as Settings);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import type {
|
import type {
|
||||||
FileFilteringOptions,
|
FileFilteringOptions,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
OutputFormat,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { extensionsCommand } from '../commands/extensions.js';
|
import { extensionsCommand } from '../commands/extensions.js';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +23,9 @@ import {
|
|||||||
WriteFileTool,
|
WriteFileTool,
|
||||||
resolveTelemetrySettings,
|
resolveTelemetrySettings,
|
||||||
FatalConfigError,
|
FatalConfigError,
|
||||||
|
Storage,
|
||||||
|
InputFormat,
|
||||||
|
OutputFormat,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
import yargs, { type Argv } from 'yargs';
|
import yargs, { type Argv } from 'yargs';
|
||||||
@@ -124,7 +126,24 @@ export interface CliArgs {
|
|||||||
screenReader: boolean | undefined;
|
screenReader: boolean | undefined;
|
||||||
vlmSwitchMode: string | undefined;
|
vlmSwitchMode: string | undefined;
|
||||||
useSmartEdit: boolean | undefined;
|
useSmartEdit: boolean | undefined;
|
||||||
|
inputFormat?: string | undefined;
|
||||||
outputFormat: 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> {
|
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||||
@@ -359,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 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'],
|
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', {
|
.option('output-format', {
|
||||||
alias: 'o',
|
alias: 'o',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The format of the CLI output.',
|
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(
|
.deprecateOption(
|
||||||
'show-memory-usage',
|
'show-memory-usage',
|
||||||
@@ -408,6 +439,18 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
if (argv['yolo'] && argv['approvalMode']) {
|
if (argv['yolo'] && argv['approvalMode']) {
|
||||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
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;
|
return true;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -560,6 +603,20 @@ export async function loadCliConfig(
|
|||||||
(e) => e.contextFiles,
|
(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 fileService = new FileDiscoveryService(cwd);
|
||||||
|
|
||||||
const fileFiltering = {
|
const fileFiltering = {
|
||||||
@@ -588,6 +645,22 @@ export async function loadCliConfig(
|
|||||||
|
|
||||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||||
const question = argv.promptInteractive || argv.prompt || '';
|
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
|
// Determine approval mode with backward compatibility
|
||||||
let approvalMode: ApprovalMode;
|
let approvalMode: ApprovalMode;
|
||||||
@@ -629,11 +702,31 @@ export async function loadCliConfig(
|
|||||||
throw err;
|
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 hasQuery = !!argv.query;
|
||||||
const interactive =
|
const hasPrompt = !!argv.prompt;
|
||||||
!!argv.promptInteractive ||
|
let interactive: boolean;
|
||||||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
|
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.
|
// In non-interactive mode, exclude tools that require a prompt.
|
||||||
const extraExcludes: string[] = [];
|
const extraExcludes: string[] = [];
|
||||||
if (!interactive && !argv.experimentalAcp) {
|
if (!interactive && !argv.experimentalAcp) {
|
||||||
@@ -755,6 +848,9 @@ export async function loadCliConfig(
|
|||||||
blockedMcpServers,
|
blockedMcpServers,
|
||||||
noBrowser: !!process.env['NO_BROWSER'],
|
noBrowser: !!process.env['NO_BROWSER'],
|
||||||
authType: settings.security?.auth?.selectedType,
|
authType: settings.security?.auth?.selectedType,
|
||||||
|
inputFormat,
|
||||||
|
outputFormat,
|
||||||
|
includePartialMessages,
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
...(settings.model?.generationConfig || {}),
|
...(settings.model?.generationConfig || {}),
|
||||||
model: resolvedModel,
|
model: resolvedModel,
|
||||||
@@ -798,7 +894,7 @@ export async function loadCliConfig(
|
|||||||
eventEmitter: appEvents,
|
eventEmitter: appEvents,
|
||||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||||
output: {
|
output: {
|
||||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
format: outputSettingsFormat,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
function findEnvFile(startDir: string): string | null {
|
||||||
let currentDir = path.resolve(startDir);
|
let currentDir = path.resolve(startDir);
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -176,6 +176,23 @@ const SETTINGS_SCHEMA = {
|
|||||||
description: 'Enable debug logging of keystrokes to the console.',
|
description: 'Enable debug logging of keystrokes to the console.',
|
||||||
showInDialog: true,
|
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: {
|
output: {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||||
import { performInitialAuth } from './auth.js';
|
import { performInitialAuth } from './auth.js';
|
||||||
import { validateTheme } from './theme.js';
|
import { validateTheme } from './theme.js';
|
||||||
|
import { initializeI18n } from '../i18n/index.js';
|
||||||
|
|
||||||
export interface InitializationResult {
|
export interface InitializationResult {
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
@@ -33,6 +34,13 @@ export async function initializeApp(
|
|||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
): Promise<InitializationResult> {
|
): Promise<InitializationResult> {
|
||||||
|
// 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 authType = settings.merged.security?.auth?.selectedType;
|
||||||
const authError = await performInitialAuth(config, authType);
|
const authError = await performInitialAuth(config, authType);
|
||||||
|
|
||||||
@@ -44,7 +52,6 @@ export async function initializeApp(
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeError = validateTheme(settings);
|
const themeError = validateTheme(settings);
|
||||||
|
|
||||||
const shouldOpenAuthDialog =
|
const shouldOpenAuthDialog =
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { themeManager } from '../ui/themes/theme-manager.js';
|
import { themeManager } from '../ui/themes/theme-manager.js';
|
||||||
import { type LoadedSettings } from '../config/settings.js';
|
import { type LoadedSettings } from '../config/settings.js';
|
||||||
|
import { t } from '../i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the configured theme.
|
* Validates the configured theme.
|
||||||
@@ -15,7 +16,9 @@ import { type LoadedSettings } from '../config/settings.js';
|
|||||||
export function validateTheme(settings: LoadedSettings): string | null {
|
export function validateTheme(settings: LoadedSettings): string | null {
|
||||||
const effectiveTheme = settings.merged.ui?.theme;
|
const effectiveTheme = settings.merged.ui?.theme;
|
||||||
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
|
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
|
||||||
return `Theme "${effectiveTheme}" not found.`;
|
return t('Theme "{{themeName}}" not found.', {
|
||||||
|
themeName: effectiveTheme,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { type LoadedSettings } from './config/settings.js';
|
import { type LoadedSettings } from './config/settings.js';
|
||||||
import { appEvents, AppEvent } from './utils/events.js';
|
import { appEvents, AppEvent } from './utils/events.js';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
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
|
// Custom error to identify mock process.exit calls
|
||||||
class MockProcessExitError extends Error {
|
class MockProcessExitError extends Error {
|
||||||
@@ -158,6 +159,7 @@ describe('gemini.tsx main function', () => {
|
|||||||
getScreenReader: () => false,
|
getScreenReader: () => false,
|
||||||
getGeminiMdFileCount: () => 0,
|
getGeminiMdFileCount: () => 0,
|
||||||
getProjectRoot: () => '/',
|
getProjectRoot: () => '/',
|
||||||
|
getOutputFormat: () => OutputFormat.TEXT,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
});
|
});
|
||||||
vi.mocked(loadSettings).mockReturnValue({
|
vi.mocked(loadSettings).mockReturnValue({
|
||||||
@@ -230,6 +232,143 @@ describe('gemini.tsx main function', () => {
|
|||||||
// Avoid the process.exit error from being thrown.
|
// Avoid the process.exit error from being thrown.
|
||||||
processExitSpy.mockRestore();
|
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', () => {
|
describe('gemini.tsx main function kitty protocol', () => {
|
||||||
@@ -337,7 +476,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
screenReader: undefined,
|
screenReader: undefined,
|
||||||
vlmSwitchMode: undefined,
|
vlmSwitchMode: undefined,
|
||||||
useSmartEdit: undefined,
|
useSmartEdit: undefined,
|
||||||
|
inputFormat: undefined,
|
||||||
outputFormat: undefined,
|
outputFormat: undefined,
|
||||||
|
includePartialMessages: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await main();
|
await main();
|
||||||
@@ -412,6 +553,7 @@ describe('startInteractiveUI', () => {
|
|||||||
vi.mock('./utils/cleanup.js', () => ({
|
vi.mock('./utils/cleanup.js', () => ({
|
||||||
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
|
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
|
||||||
registerCleanup: vi.fn(),
|
registerCleanup: vi.fn(),
|
||||||
|
runExitCleanup: vi.fn(() => Promise.resolve()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('ink', () => ({
|
vi.mock('ink', () => ({
|
||||||
|
|||||||
@@ -4,58 +4,60 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { render } from 'ink';
|
||||||
import { AppContainer } from './ui/AppContainer.js';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
import dns from 'node:dns';
|
||||||
import * as cliConfig from './config/config.js';
|
import os from 'node:os';
|
||||||
import { readStdin } from './utils/readStdin.js';
|
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import v8 from 'node:v8';
|
import v8 from 'node:v8';
|
||||||
import os from 'node:os';
|
import React from 'react';
|
||||||
import dns from 'node:dns';
|
import { validateAuthMethod } from './config/auth.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import * as cliConfig from './config/config.js';
|
||||||
import { start_sandbox } from './utils/sandbox.js';
|
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||||
|
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||||
import { themeManager } from './ui/themes/theme-manager.js';
|
import {
|
||||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
initializeApp,
|
||||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
type InitializationResult,
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
} from './core/initializer.js';
|
||||||
import { runNonInteractive } from './nonInteractiveCli.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 {
|
import {
|
||||||
cleanupCheckpoints,
|
cleanupCheckpoints,
|
||||||
registerCleanup,
|
registerCleanup,
|
||||||
runExitCleanup,
|
runExitCleanup,
|
||||||
} from './utils/cleanup.js';
|
} from './utils/cleanup.js';
|
||||||
import { getCliVersion } from './utils/version.js';
|
import { AppEvent, appEvents } from './utils/events.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 { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
import { readStdin } from './utils/readStdin.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 {
|
import {
|
||||||
relaunchOnExitCode,
|
|
||||||
relaunchAppInChildProcess,
|
relaunchAppInChildProcess,
|
||||||
|
relaunchOnExitCode,
|
||||||
} from './utils/relaunch.js';
|
} 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';
|
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||||
|
|
||||||
export function validateDnsResolutionOrder(
|
export function validateDnsResolutionOrder(
|
||||||
@@ -106,9 +108,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
|
||||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
|
||||||
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
||||||
|
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||||
|
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||||
|
|
||||||
export function setupUnhandledRejectionHandler() {
|
export function setupUnhandledRejectionHandler() {
|
||||||
let unhandledRejectionOccurred = false;
|
let unhandledRejectionOccurred = false;
|
||||||
@@ -218,12 +220,6 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||||
const consolePatcher = new ConsolePatcher({
|
|
||||||
stderr: true,
|
|
||||||
debugMode: isDebugMode,
|
|
||||||
});
|
|
||||||
consolePatcher.patch();
|
|
||||||
registerCleanup(consolePatcher.cleanup);
|
|
||||||
|
|
||||||
dns.setDefaultResultOrder(
|
dns.setDefaultResultOrder(
|
||||||
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
|
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
|
||||||
@@ -348,6 +344,15 @@ export async function main() {
|
|||||||
process.exit(0);
|
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;
|
const wasRaw = process.stdin.isRaw;
|
||||||
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
|
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
|
||||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||||
@@ -410,14 +415,43 @@ export async function main() {
|
|||||||
|
|
||||||
await config.initialize();
|
await config.initialize();
|
||||||
|
|
||||||
// If not a TTY, read from stdin
|
// Check input format BEFORE reading stdin
|
||||||
// This is for cases where the user pipes input directly into the command
|
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
|
||||||
if (!process.stdin.isTTY) {
|
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();
|
const stdinData = await readStdin();
|
||||||
if (stdinData) {
|
if (stdinData) {
|
||||||
input = `${stdinData}\n\n${input}`;
|
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) {
|
if (!input) {
|
||||||
console.error(
|
console.error(
|
||||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
`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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt_id = Math.random().toString(16).slice(2);
|
|
||||||
logUserPrompt(config, {
|
logUserPrompt(config, {
|
||||||
'event.name': 'user_prompt',
|
'event.name': 'user_prompt',
|
||||||
'event.timestamp': new Date().toISOString(),
|
'event.timestamp': new Date().toISOString(),
|
||||||
@@ -435,13 +468,6 @@ export async function main() {
|
|||||||
prompt_length: input.length,
|
prompt_length: input.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
|
||||||
settings.merged.security?.auth?.selectedType,
|
|
||||||
settings.merged.security?.auth?.useExternal,
|
|
||||||
config,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (config.getDebugMode()) {
|
if (config.getDebugMode()) {
|
||||||
console.log('Session ID: %s', sessionId);
|
console.log('Session ID: %s', sessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
232
packages/cli/src/i18n/index.ts
Normal file
232
packages/cli/src/i18n/index.ts
Normal 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');
|
||||||
|
}
|
||||||
1129
packages/cli/src/i18n/locales/en.js
Normal file
1129
packages/cli/src/i18n/locales/en.js
Normal file
File diff suppressed because it is too large
Load Diff
1052
packages/cli/src/i18n/locales/zh.js
Normal file
1052
packages/cli/src/i18n/locales/zh.js
Normal file
File diff suppressed because it is too large
Load Diff
76
packages/cli/src/nonInteractive/control/ControlContext.ts
Normal file
76
packages/cli/src/nonInteractive/control/ControlContext.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
353
packages/cli/src/nonInteractive/control/ControlDispatcher.ts
Normal file
353
packages/cli/src/nonInteractive/control/ControlDispatcher.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
packages/cli/src/nonInteractive/control/ControlService.ts
Normal file
191
packages/cli/src/nonInteractive/control/ControlService.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
139
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts
Normal file
139
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts
Normal 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;
|
||||||
|
}
|
||||||
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal 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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
591
packages/cli/src/nonInteractive/session.test.ts
Normal file
591
packages/cli/src/nonInteractive/session.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
721
packages/cli/src/nonInteractive/session.ts
Normal file
721
packages/cli/src/nonInteractive/session.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
509
packages/cli/src/nonInteractive/types.ts
Normal file
509
packages/cli/src/nonInteractive/types.ts
Normal 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
@@ -15,14 +15,16 @@ import {
|
|||||||
FatalInputError,
|
FatalInputError,
|
||||||
promptIdContext,
|
promptIdContext,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
JsonFormatter,
|
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||||
import type { Content, Part } 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 { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
|
||||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||||
import {
|
import {
|
||||||
handleError,
|
handleError,
|
||||||
@@ -30,73 +32,144 @@ import {
|
|||||||
handleCancellationError,
|
handleCancellationError,
|
||||||
handleMaxTurnsExceededError,
|
handleMaxTurnsExceededError,
|
||||||
} from './utils/errors.js';
|
} 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(
|
export async function runNonInteractive(
|
||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
input: string,
|
input: string,
|
||||||
prompt_id: string,
|
prompt_id: string,
|
||||||
|
options: RunNonInteractiveOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return promptIdContext.run(prompt_id, async () => {
|
return promptIdContext.run(prompt_id, async () => {
|
||||||
const consolePatcher = new ConsolePatcher({
|
// Create output adapter based on format
|
||||||
stderr: true,
|
let adapter: JsonOutputAdapterInterface | undefined;
|
||||||
debugMode: config.getDebugMode(),
|
const outputFormat = config.getOutputFormat();
|
||||||
});
|
|
||||||
|
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') {
|
||||||
|
process.stdout.removeListener('error', stdoutErrorHandler);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const geminiClient = config.getGeminiClient();
|
||||||
|
const abortController = options.abortController ?? new AbortController();
|
||||||
|
|
||||||
|
// Setup signal handlers for graceful shutdown
|
||||||
|
const shutdownHandler = () => {
|
||||||
|
if (config.getDebugMode()) {
|
||||||
|
console.error('[runNonInteractive] Shutdown signal received');
|
||||||
|
}
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
consolePatcher.patch();
|
process.stdout.on('error', stdoutErrorHandler);
|
||||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
|
||||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
|
||||||
if (err.code === 'EPIPE') {
|
|
||||||
// Exit gracefully if the pipe is closed.
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const geminiClient = config.getGeminiClient();
|
process.on('SIGINT', shutdownHandler);
|
||||||
|
process.on('SIGTERM', shutdownHandler);
|
||||||
|
|
||||||
const abortController = new AbortController();
|
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
||||||
|
options.userMessage,
|
||||||
|
);
|
||||||
|
|
||||||
let query: Part[] | undefined;
|
if (!initialPartList) {
|
||||||
|
let slashHandled = false;
|
||||||
if (isSlashCommand(input)) {
|
if (isSlashCommand(input)) {
|
||||||
const slashCommandResult = await handleSlashCommand(
|
const slashCommandResult = await handleSlashCommand(
|
||||||
input,
|
input,
|
||||||
abortController,
|
abortController,
|
||||||
config,
|
config,
|
||||||
settings,
|
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[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
const { processedQuery, shouldProceed } = await handleAtCommand({
|
|
||||||
query: input,
|
|
||||||
config,
|
|
||||||
addItem: (_item, _timestamp) => 0,
|
|
||||||
onDebugMessage: () => {},
|
|
||||||
messageId: Date.now(),
|
|
||||||
signal: abortController.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!shouldProceed || !processedQuery) {
|
|
||||||
// An error occurred during @include processing (e.g., file not found).
|
|
||||||
// The error message is already logged by handleAtCommand.
|
|
||||||
throw new FatalInputError(
|
|
||||||
'Exiting due to an error processing the @ command.',
|
|
||||||
);
|
);
|
||||||
|
if (slashCommandResult) {
|
||||||
|
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||||
|
initialPartList = slashCommandResult as PartListUnion;
|
||||||
|
slashHandled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slashHandled) {
|
||||||
|
const { processedQuery, shouldProceed } = await handleAtCommand({
|
||||||
|
query: input,
|
||||||
|
config,
|
||||||
|
addItem: (_item, _timestamp) => 0,
|
||||||
|
onDebugMessage: () => {},
|
||||||
|
messageId: Date.now(),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldProceed || !processedQuery) {
|
||||||
|
// An error occurred during @include processing (e.g., file not found).
|
||||||
|
// The error message is already logged by handleAtCommand.
|
||||||
|
throw new FatalInputError(
|
||||||
|
'Exiting due to an error processing the @ command.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
initialPartList = processedQuery as PartListUnion;
|
||||||
}
|
}
|
||||||
query = processedQuery as Part[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
while (true) {
|
||||||
turnCount++;
|
turnCount++;
|
||||||
if (
|
if (
|
||||||
@@ -105,43 +178,124 @@ export async function runNonInteractive(
|
|||||||
) {
|
) {
|
||||||
handleMaxTurnsExceededError(config);
|
handleMaxTurnsExceededError(config);
|
||||||
}
|
}
|
||||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
|
||||||
|
|
||||||
|
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||||
|
const apiStartTime = Date.now();
|
||||||
const responseStream = geminiClient.sendMessageStream(
|
const responseStream = geminiClient.sendMessageStream(
|
||||||
currentMessages[0]?.parts || [],
|
currentMessages[0]?.parts || [],
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
prompt_id,
|
prompt_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
let responseText = '';
|
// Start assistant message for this turn
|
||||||
|
if (adapter) {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
}
|
||||||
|
|
||||||
for await (const event of responseStream) {
|
for await (const event of responseStream) {
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
handleCancellationError(config);
|
handleCancellationError(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === GeminiEventType.Content) {
|
if (adapter) {
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
// Use adapter for all event processing
|
||||||
responseText += event.value;
|
adapter.processEvent(event);
|
||||||
} else {
|
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||||
process.stdout.write(event.value);
|
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);
|
||||||
}
|
}
|
||||||
} 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) {
|
if (toolCallRequests.length > 0) {
|
||||||
const toolResponseParts: Part[] = [];
|
const toolResponseParts: Part[] = [];
|
||||||
|
|
||||||
for (const requestInfo of toolCallRequests) {
|
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(
|
const toolResponse = await executeToolCall(
|
||||||
config,
|
config,
|
||||||
requestInfo,
|
finalRequestInfo,
|
||||||
abortController.signal,
|
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) {
|
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(
|
handleToolError(
|
||||||
requestInfo.name,
|
finalRequestInfo.name,
|
||||||
toolResponse.error,
|
toolResponse.error,
|
||||||
config,
|
config,
|
||||||
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||||
@@ -149,6 +303,13 @@ export async function runNonInteractive(
|
|||||||
? toolResponse.resultDisplay
|
? toolResponse.resultDisplay
|
||||||
: undefined,
|
: 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) {
|
if (toolResponse.responseParts) {
|
||||||
@@ -157,20 +318,57 @@ export async function runNonInteractive(
|
|||||||
}
|
}
|
||||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||||
} else {
|
} else {
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
// For JSON and STREAM_JSON modes, compute usage from metrics
|
||||||
const formatter = new JsonFormatter();
|
if (adapter) {
|
||||||
const stats = uiTelemetryService.getMetrics();
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
process.stdout.write(formatter.format(responseText, stats));
|
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 {
|
} else {
|
||||||
process.stdout.write('\n'); // Ensure a final newline
|
// Text output mode - no usage needed
|
||||||
|
process.stdout.write('\n');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
handleError(error, config);
|
||||||
} finally {
|
} finally {
|
||||||
consolePatcher.cleanup();
|
process.stdout.removeListener('error', stdoutErrorHandler);
|
||||||
|
// Cleanup signal handlers
|
||||||
|
process.removeListener('SIGINT', shutdownHandler);
|
||||||
|
process.removeListener('SIGTERM', shutdownHandler);
|
||||||
if (isTelemetrySdkInitialized()) {
|
if (isTelemetrySdkInitialized()) {
|
||||||
await shutdownTelemetry(config);
|
await shutdownTelemetry(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
|||||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||||
import { initCommand } from '../ui/commands/initCommand.js';
|
import { initCommand } from '../ui/commands/initCommand.js';
|
||||||
|
import { languageCommand } from '../ui/commands/languageCommand.js';
|
||||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||||
@@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||||||
helpCommand,
|
helpCommand,
|
||||||
await ideCommand(),
|
await ideCommand(),
|
||||||
initCommand,
|
initCommand,
|
||||||
|
languageCommand,
|
||||||
mcpCommand,
|
mcpCommand,
|
||||||
memoryCommand,
|
memoryCommand,
|
||||||
modelCommand,
|
modelCommand,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ import { useGitBranchName } from './hooks/useGitBranchName.js';
|
|||||||
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
|
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
|
||||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||||
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
||||||
|
import { t } from '../i18n/index.js';
|
||||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||||
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
||||||
@@ -384,7 +385,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
settings.merged.security?.auth.selectedType
|
settings.merged.security?.auth.selectedType
|
||||||
) {
|
) {
|
||||||
onAuthError(
|
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 (
|
} else if (
|
||||||
settings.merged.security?.auth?.selectedType &&
|
settings.merged.security?.auth?.selectedType &&
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
|||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
import { useSettings } from '../contexts/SettingsContext.js';
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
function parseDefaultAuthType(
|
function parseDefaultAuthType(
|
||||||
defaultAuthType: string | undefined,
|
defaultAuthType: string | undefined,
|
||||||
@@ -39,10 +40,14 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
key: AuthType.QWEN_OAUTH,
|
key: AuthType.QWEN_OAUTH,
|
||||||
label: 'Qwen OAuth',
|
label: t('Qwen OAuth'),
|
||||||
value: AuthType.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(
|
const initialAuthIndex = Math.max(
|
||||||
@@ -98,7 +103,9 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
if (settings.merged.security?.auth?.selectedType === undefined) {
|
if (settings.merged.security?.auth?.selectedType === undefined) {
|
||||||
// Prevent exiting if no auth method is set
|
// Prevent exiting if no auth method is set
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
t(
|
||||||
|
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -116,9 +123,9 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
padding={1}
|
padding={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text bold>Get started</Text>
|
<Text bold>{t('Get started')}</Text>
|
||||||
<Box marginTop={1}>
|
<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>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<RadioButtonSelect
|
<RadioButtonSelect
|
||||||
@@ -134,19 +141,19 @@ export function AuthDialog(): React.JSX.Element {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box marginTop={1}>
|
<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>
|
</Box>
|
||||||
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={Colors.Gray}>
|
<Text color={Colors.Gray}>
|
||||||
Note: Your existing API key in settings.json will not be cleared
|
{t(
|
||||||
when using Qwen OAuth. You can switch back to OpenAI authentication
|
'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.',
|
||||||
later if needed.
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
|
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={Colors.AccentBlue}>
|
<Text color={Colors.AccentBlue}>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Box, Text } from 'ink';
|
|||||||
import Spinner from 'ink-spinner';
|
import Spinner from 'ink-spinner';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface AuthInProgressProps {
|
interface AuthInProgressProps {
|
||||||
onTimeout: () => void;
|
onTimeout: () => void;
|
||||||
@@ -48,13 +49,13 @@ export function AuthInProgress({
|
|||||||
>
|
>
|
||||||
{timedOut ? (
|
{timedOut ? (
|
||||||
<Text color={theme.status.error}>
|
<Text color={theme.status.error}>
|
||||||
Authentication timed out. Please try again.
|
{t('Authentication timed out. Please try again.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Box>
|
<Box>
|
||||||
<Text>
|
<Text>
|
||||||
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
|
<Spinner type="dots" />{' '}
|
||||||
cancel)
|
{t('Waiting for auth... (Press ESC or CTRL+C to cancel)')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
|||||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||||
import { AuthState, MessageType } from '../types.js';
|
import { AuthState, MessageType } from '../types.js';
|
||||||
import type { HistoryItem } from '../types.js';
|
import type { HistoryItem } from '../types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||||
|
|
||||||
@@ -60,7 +61,9 @@ export const useAuthCommand = (
|
|||||||
const handleAuthFailure = useCallback(
|
const handleAuthFailure = useCallback(
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
setIsAuthenticating(false);
|
setIsAuthenticating(false);
|
||||||
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
|
const errorMessage = t('Failed to authenticate. Message: {{message}}', {
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
});
|
||||||
onAuthError(errorMessage);
|
onAuthError(errorMessage);
|
||||||
|
|
||||||
// Log authentication failure
|
// Log authentication failure
|
||||||
@@ -127,7 +130,9 @@ export const useAuthCommand = (
|
|||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Authenticated successfully with ${authType} credentials.`,
|
text: t('Authenticated successfully with {{authType}} credentials.', {
|
||||||
|
authType,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -225,7 +230,13 @@ export const useAuthCommand = (
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
onAuthError(
|
onAuthError(
|
||||||
`Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`,
|
t(
|
||||||
|
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}',
|
||||||
|
{
|
||||||
|
value: defaultAuthType,
|
||||||
|
validValues: [AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', '),
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [onAuthError]);
|
}, [onAuthError]);
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import type { SlashCommand } from './types.js';
|
|||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { MessageType, type HistoryItemAbout } from '../types.js';
|
import { MessageType, type HistoryItemAbout } from '../types.js';
|
||||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const aboutCommand: SlashCommand = {
|
export const aboutCommand: SlashCommand = {
|
||||||
name: 'about',
|
name: 'about',
|
||||||
description: 'show version info',
|
get description() {
|
||||||
|
return t('show version info');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const systemInfo = await getExtendedSystemInfo(context);
|
const systemInfo = await getExtendedSystemInfo(context);
|
||||||
|
|||||||
@@ -9,15 +9,20 @@ import {
|
|||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
type OpenDialogActionReturn,
|
type OpenDialogActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const agentsCommand: SlashCommand = {
|
export const agentsCommand: SlashCommand = {
|
||||||
name: 'agents',
|
name: 'agents',
|
||||||
description: 'Manage subagents for specialized task delegation.',
|
get description() {
|
||||||
|
return t('Manage subagents for specialized task delegation.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
{
|
||||||
name: 'manage',
|
name: 'manage',
|
||||||
description: 'Manage existing subagents (view, edit, delete).',
|
get description() {
|
||||||
|
return t('Manage existing subagents (view, edit, delete).');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (): OpenDialogActionReturn => ({
|
action: (): OpenDialogActionReturn => ({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
@@ -26,7 +31,9 @@ export const agentsCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'create',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (): OpenDialogActionReturn => ({
|
action: (): OpenDialogActionReturn => ({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import type {
|
|||||||
OpenDialogActionReturn,
|
OpenDialogActionReturn,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const approvalModeCommand: SlashCommand = {
|
export const approvalModeCommand: SlashCommand = {
|
||||||
name: 'approval-mode',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
_context: CommandContext,
|
_context: CommandContext,
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const authCommand: SlashCommand = {
|
export const authCommand: SlashCommand = {
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
description: 'change the auth method',
|
get description() {
|
||||||
|
return t('change the auth method');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (_context, _args): OpenDialogActionReturn => ({
|
action: (_context, _args): OpenDialogActionReturn => ({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ import {
|
|||||||
getSystemInfoFields,
|
getSystemInfoFields,
|
||||||
getFieldValue,
|
getFieldValue,
|
||||||
} from '../../utils/systemInfoFields.js';
|
} from '../../utils/systemInfoFields.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const bugCommand: SlashCommand = {
|
export const bugCommand: SlashCommand = {
|
||||||
name: 'bug',
|
name: 'bug',
|
||||||
description: 'submit a bug report',
|
get description() {
|
||||||
|
return t('submit a bug report');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||||
const bugDescription = (args || '').trim();
|
const bugDescription = (args || '').trim();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import * as fsPromises from 'node:fs/promises';
|
import * as fsPromises from 'node:fs/promises';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
|
||||||
import type {
|
import type {
|
||||||
CommandContext,
|
CommandContext,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
@@ -20,6 +19,7 @@ import path from 'node:path';
|
|||||||
import type { HistoryItemWithoutId } from '../types.js';
|
import type { HistoryItemWithoutId } from '../types.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { Content } from '@google/genai';
|
import type { Content } from '@google/genai';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface ChatDetail {
|
interface ChatDetail {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -67,7 +67,9 @@ const getSavedChatTags = async (
|
|||||||
|
|
||||||
const listCommand: SlashCommand = {
|
const listCommand: SlashCommand = {
|
||||||
name: 'list',
|
name: 'list',
|
||||||
description: 'List saved conversation checkpoints',
|
get description() {
|
||||||
|
return t('List saved conversation checkpoints');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context): Promise<MessageActionReturn> => {
|
action: async (context): Promise<MessageActionReturn> => {
|
||||||
const chatDetails = await getSavedChatTags(context, false);
|
const chatDetails = await getSavedChatTags(context, false);
|
||||||
@@ -75,7 +77,7 @@ const listCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
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),
|
...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) {
|
for (const chat of chatDetails) {
|
||||||
const paddedName = chat.name.padEnd(maxNameLength, ' ');
|
const paddedName = chat.name.padEnd(maxNameLength, ' ');
|
||||||
const isoString = chat.mtime.toISOString();
|
const isoString = chat.mtime.toISOString();
|
||||||
@@ -91,7 +93,7 @@ const listCommand: SlashCommand = {
|
|||||||
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
|
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
|
||||||
message += ` - ${paddedName} (saved on ${formattedDate})\n`;
|
message += ` - ${paddedName} (saved on ${formattedDate})\n`;
|
||||||
}
|
}
|
||||||
message += `\nNote: Newest last, oldest first`;
|
message += `\n${t('Note: Newest last, oldest first')}`;
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
@@ -102,8 +104,11 @@ const listCommand: SlashCommand = {
|
|||||||
|
|
||||||
const saveCommand: SlashCommand = {
|
const saveCommand: SlashCommand = {
|
||||||
name: 'save',
|
name: 'save',
|
||||||
description:
|
get description() {
|
||||||
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
return t(
|
||||||
|
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
||||||
|
);
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
|
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
|
||||||
const tag = args.trim();
|
const tag = args.trim();
|
||||||
@@ -111,7 +116,7 @@ const saveCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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(
|
prompt: React.createElement(
|
||||||
Text,
|
Text,
|
||||||
null,
|
null,
|
||||||
'A checkpoint with the tag ',
|
t(
|
||||||
React.createElement(Text, { color: theme.text.accent }, tag),
|
'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?',
|
||||||
' already exists. Do you want to overwrite it?',
|
{
|
||||||
|
tag,
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
originalInvocation: {
|
originalInvocation: {
|
||||||
raw: context.invocation?.raw || `/chat save ${tag}`,
|
raw: context.invocation?.raw || `/chat save ${tag}`,
|
||||||
@@ -142,7 +150,7 @@ const saveCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: `Conversation checkpoint saved with tag: ${decodeTagName(tag)}.`,
|
content: t('Conversation checkpoint saved with tag: {{tag}}.', {
|
||||||
|
tag: decodeTagName(tag),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
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 = {
|
const resumeCommand: SlashCommand = {
|
||||||
name: 'resume',
|
name: 'resume',
|
||||||
altNames: ['load'],
|
altNames: ['load'],
|
||||||
description:
|
get description() {
|
||||||
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
|
return t(
|
||||||
|
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
|
||||||
|
);
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context, args) => {
|
action: async (context, args) => {
|
||||||
const tag = args.trim();
|
const tag = args.trim();
|
||||||
@@ -176,7 +189,7 @@ const resumeCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
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 = {
|
const deleteCommand: SlashCommand = {
|
||||||
name: 'delete',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context, args): Promise<MessageActionReturn> => {
|
action: async (context, args): Promise<MessageActionReturn> => {
|
||||||
const tag = args.trim();
|
const tag = args.trim();
|
||||||
@@ -245,7 +262,7 @@ const deleteCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`,
|
content: t("Conversation checkpoint '{{tag}}' has been deleted.", {
|
||||||
|
tag: decodeTagName(tag),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 = {
|
const shareCommand: SlashCommand = {
|
||||||
name: 'share',
|
name: 'share',
|
||||||
description:
|
get description() {
|
||||||
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
|
return t(
|
||||||
|
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
|
||||||
|
);
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context, args): Promise<MessageActionReturn> => {
|
action: async (context, args): Promise<MessageActionReturn> => {
|
||||||
let filePathArg = args.trim();
|
let filePathArg = args.trim();
|
||||||
@@ -324,7 +348,7 @@ const shareCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: 'No conversation found to share.',
|
content: t('No conversation found to share.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,14 +386,18 @@ const shareCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: `Conversation shared to ${filePath}`,
|
content: t('Conversation shared to {{filePath}}', {
|
||||||
|
filePath,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 = {
|
export const chatCommand: SlashCommand = {
|
||||||
name: 'chat',
|
name: 'chat',
|
||||||
description: 'Manage conversation history.',
|
get description() {
|
||||||
|
return t('Manage conversation history.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
subCommands: [
|
subCommands: [
|
||||||
listCommand,
|
listCommand,
|
||||||
|
|||||||
@@ -7,21 +7,24 @@
|
|||||||
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
|
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
|
||||||
import type { SlashCommand } from './types.js';
|
import type { SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const clearCommand: SlashCommand = {
|
export const clearCommand: SlashCommand = {
|
||||||
name: 'clear',
|
name: 'clear',
|
||||||
description: 'clear the screen and conversation history',
|
get description() {
|
||||||
|
return t('clear the screen and conversation history');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context, _args) => {
|
action: async (context, _args) => {
|
||||||
const geminiClient = context.services.config?.getGeminiClient();
|
const geminiClient = context.services.config?.getGeminiClient();
|
||||||
|
|
||||||
if (geminiClient) {
|
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,
|
// If resetChat fails, the exception will propagate and halt the command,
|
||||||
// which is the correct behavior to signal a failure to the user.
|
// which is the correct behavior to signal a failure to the user.
|
||||||
await geminiClient.resetChat();
|
await geminiClient.resetChat();
|
||||||
} else {
|
} else {
|
||||||
context.ui.setDebugMessage('Clearing terminal.');
|
context.ui.setDebugMessage(t('Clearing terminal.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
uiTelemetryService.setLastPromptTokenCount(0);
|
uiTelemetryService.setLastPromptTokenCount(0);
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ import type { HistoryItemCompression } from '../types.js';
|
|||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { SlashCommand } from './types.js';
|
import type { SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const compressCommand: SlashCommand = {
|
export const compressCommand: SlashCommand = {
|
||||||
name: 'compress',
|
name: 'compress',
|
||||||
altNames: ['summarize'],
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const { ui } = context;
|
const { ui } = context;
|
||||||
@@ -20,7 +23,7 @@ export const compressCommand: SlashCommand = {
|
|||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Already compressing, wait for previous request to complete',
|
text: t('Already compressing, wait for previous request to complete'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -60,7 +63,7 @@ export const compressCommand: SlashCommand = {
|
|||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Failed to compress chat history.',
|
text: t('Failed to compress chat history.'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -69,9 +72,9 @@ export const compressCommand: SlashCommand = {
|
|||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: `Failed to compress chat history: ${
|
text: t('Failed to compress chat history: {{error}}', {
|
||||||
e instanceof Error ? e.message : String(e)
|
error: e instanceof Error ? e.message : String(e),
|
||||||
}`,
|
}),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
import { copyToClipboard } from '../utils/commandUtils.js';
|
import { copyToClipboard } from '../utils/commandUtils.js';
|
||||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const copyCommand: SlashCommand = {
|
export const copyCommand: SlashCommand = {
|
||||||
name: 'copy',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
||||||
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { MessageType } from '../types.js';
|
|||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
|
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export function expandHomeDir(p: string): string {
|
export function expandHomeDir(p: string): string {
|
||||||
if (!p) {
|
if (!p) {
|
||||||
@@ -27,13 +28,18 @@ export function expandHomeDir(p: string): string {
|
|||||||
export const directoryCommand: SlashCommand = {
|
export const directoryCommand: SlashCommand = {
|
||||||
name: 'directory',
|
name: 'directory',
|
||||||
altNames: ['dir'],
|
altNames: ['dir'],
|
||||||
description: 'Manage workspace directories',
|
get description() {
|
||||||
|
return t('Manage workspace directories');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
{
|
||||||
name: 'add',
|
name: 'add',
|
||||||
description:
|
get description() {
|
||||||
'Add directories to the workspace. Use comma to separate multiple paths',
|
return t(
|
||||||
|
'Add directories to the workspace. Use comma to separate multiple paths',
|
||||||
|
);
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext, args: string) => {
|
action: async (context: CommandContext, args: string) => {
|
||||||
const {
|
const {
|
||||||
@@ -46,7 +52,7 @@ export const directoryCommand: SlashCommand = {
|
|||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Configuration is not available.',
|
text: t('Configuration is not available.'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -63,7 +69,7 @@ export const directoryCommand: SlashCommand = {
|
|||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Please provide at least one path to add.',
|
text: t('Please provide at least one path to add.'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -74,8 +80,9 @@ export const directoryCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message' as const,
|
type: 'message' as const,
|
||||||
messageType: 'error' 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.',
|
'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());
|
added.push(pathToAdd.trim());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as Error;
|
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(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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) {
|
if (added.length > 0) {
|
||||||
@@ -133,7 +154,9 @@ export const directoryCommand: SlashCommand = {
|
|||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
|
text: t('Successfully added directories:\n- {{directories}}', {
|
||||||
|
directories: added.join('\n- '),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -150,7 +173,9 @@ export const directoryCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'show',
|
name: 'show',
|
||||||
description: 'Show all directories in the workspace',
|
get description() {
|
||||||
|
return t('Show all directories in the workspace');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext) => {
|
action: async (context: CommandContext) => {
|
||||||
const {
|
const {
|
||||||
@@ -161,7 +186,7 @@ export const directoryCommand: SlashCommand = {
|
|||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Configuration is not available.',
|
text: t('Configuration is not available.'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -173,7 +198,9 @@ export const directoryCommand: SlashCommand = {
|
|||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Current workspace directories:\n${directoryList}`,
|
text: t('Current workspace directories:\n{{directories}}', {
|
||||||
|
directories: directoryList,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,19 +12,28 @@ import {
|
|||||||
CommandKind,
|
CommandKind,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
|
import { t, getCurrentLanguage } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const docsCommand: SlashCommand = {
|
export const docsCommand: SlashCommand = {
|
||||||
name: 'docs',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext): Promise<void> => {
|
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') {
|
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -32,7 +41,9 @@ export const docsCommand: SlashCommand = {
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Opening documentation in your browser: ${docsUrl}`,
|
text: t('Opening documentation in your browser: {{url}}', {
|
||||||
|
url: docsUrl,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import {
|
|||||||
type OpenDialogActionReturn,
|
type OpenDialogActionReturn,
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const editorCommand: SlashCommand = {
|
export const editorCommand: SlashCommand = {
|
||||||
name: 'editor',
|
name: 'editor',
|
||||||
description: 'set external editor preference',
|
get description() {
|
||||||
|
return t('set external editor preference');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (): OpenDialogActionReturn => ({
|
action: (): OpenDialogActionReturn => ({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
async function listAction(context: CommandContext) {
|
async function listAction(context: CommandContext) {
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
@@ -131,14 +132,18 @@ async function updateAction(context: CommandContext, args: string) {
|
|||||||
|
|
||||||
const listExtensionsCommand: SlashCommand = {
|
const listExtensionsCommand: SlashCommand = {
|
||||||
name: 'list',
|
name: 'list',
|
||||||
description: 'List active extensions',
|
get description() {
|
||||||
|
return t('List active extensions');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: listAction,
|
action: listAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateExtensionsCommand: SlashCommand = {
|
const updateExtensionsCommand: SlashCommand = {
|
||||||
name: 'update',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: updateAction,
|
action: updateAction,
|
||||||
completion: async (context, partialArg) => {
|
completion: async (context, partialArg) => {
|
||||||
@@ -158,7 +163,9 @@ const updateExtensionsCommand: SlashCommand = {
|
|||||||
|
|
||||||
export const extensionsCommand: SlashCommand = {
|
export const extensionsCommand: SlashCommand = {
|
||||||
name: 'extensions',
|
name: 'extensions',
|
||||||
description: 'Manage extensions',
|
get description() {
|
||||||
|
return t('Manage extensions');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
subCommands: [listExtensionsCommand, updateExtensionsCommand],
|
subCommands: [listExtensionsCommand, updateExtensionsCommand],
|
||||||
action: (context, args) =>
|
action: (context, args) =>
|
||||||
|
|||||||
@@ -7,12 +7,15 @@
|
|||||||
import type { SlashCommand } from './types.js';
|
import type { SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { MessageType, type HistoryItemHelp } from '../types.js';
|
import { MessageType, type HistoryItemHelp } from '../types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const helpCommand: SlashCommand = {
|
export const helpCommand: SlashCommand = {
|
||||||
name: 'help',
|
name: 'help',
|
||||||
altNames: ['?'],
|
altNames: ['?'],
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
description: 'for help on Qwen Code',
|
get description() {
|
||||||
|
return t('for help on Qwen Code');
|
||||||
|
},
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const helpItem: Omit<HistoryItemHelp, 'id'> = {
|
const helpItem: Omit<HistoryItemHelp, 'id'> = {
|
||||||
type: MessageType.HELP,
|
type: MessageType.HELP,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
function getIdeStatusMessage(ideClient: IdeClient): {
|
function getIdeStatusMessage(ideClient: IdeClient): {
|
||||||
messageType: 'info' | 'error';
|
messageType: 'info' | 'error';
|
||||||
@@ -138,27 +139,35 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||||||
if (!currentIDE) {
|
if (!currentIDE) {
|
||||||
return {
|
return {
|
||||||
name: 'ide',
|
name: 'ide',
|
||||||
description: 'manage IDE integration',
|
get description() {
|
||||||
|
return t('manage IDE integration');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (): SlashCommandActionReturn =>
|
action: (): SlashCommandActionReturn =>
|
||||||
({
|
({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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,
|
}) as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ideSlashCommand: SlashCommand = {
|
const ideSlashCommand: SlashCommand = {
|
||||||
name: 'ide',
|
name: 'ide',
|
||||||
description: 'manage IDE integration',
|
get description() {
|
||||||
|
return t('manage IDE integration');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
subCommands: [],
|
subCommands: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusCommand: SlashCommand = {
|
const statusCommand: SlashCommand = {
|
||||||
name: 'status',
|
name: 'status',
|
||||||
description: 'check status of IDE integration',
|
get description() {
|
||||||
|
return t('check status of IDE integration');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (): Promise<SlashCommandActionReturn> => {
|
action: async (): Promise<SlashCommandActionReturn> => {
|
||||||
const { messageType, content } =
|
const { messageType, content } =
|
||||||
@@ -173,7 +182,12 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||||||
|
|
||||||
const installCommand: SlashCommand = {
|
const installCommand: SlashCommand = {
|
||||||
name: 'install',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const installer = getIdeInstaller(currentIDE);
|
const installer = getIdeInstaller(currentIDE);
|
||||||
@@ -246,7 +260,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||||||
|
|
||||||
const enableCommand: SlashCommand = {
|
const enableCommand: SlashCommand = {
|
||||||
name: 'enable',
|
name: 'enable',
|
||||||
description: 'enable IDE integration',
|
get description() {
|
||||||
|
return t('enable IDE integration');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext) => {
|
action: async (context: CommandContext) => {
|
||||||
context.services.settings.setValue(
|
context.services.settings.setValue(
|
||||||
@@ -268,7 +284,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
|||||||
|
|
||||||
const disableCommand: SlashCommand = {
|
const disableCommand: SlashCommand = {
|
||||||
name: 'disable',
|
name: 'disable',
|
||||||
description: 'disable IDE integration',
|
get description() {
|
||||||
|
return t('disable IDE integration');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext) => {
|
action: async (context: CommandContext) => {
|
||||||
context.services.settings.setValue(
|
context.services.settings.setValue(
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
|
|||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const initCommand: SlashCommand = {
|
export const initCommand: SlashCommand = {
|
||||||
name: 'init',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -28,7 +31,7 @@ export const initCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Configuration not available.',
|
content: t('Configuration not available.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const targetDir = context.services.config.getTargetDir();
|
const targetDir = context.services.config.getTargetDir();
|
||||||
|
|||||||
458
packages/cli/src/ui/commands/languageCommand.ts
Normal file
458
packages/cli/src/ui/commands/languageCommand.ts
Normal 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: 'English(en-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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -24,10 +24,13 @@ import {
|
|||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
const authCommand: SlashCommand = {
|
const authCommand: SlashCommand = {
|
||||||
name: 'auth',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -40,7 +43,7 @@ const authCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Config not loaded.',
|
content: t('Config not loaded.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,14 +59,14 @@ const authCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: 'No MCP servers configured with OAuth authentication.',
|
content: t('No MCP servers configured with OAuth authentication.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: `Starting OAuth authentication for MCP server '${serverName}'...`,
|
text: t(
|
||||||
|
"Starting OAuth authentication for MCP server '{{name}}'...",
|
||||||
|
{
|
||||||
|
name: serverName,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -111,7 +119,12 @@ const authCommand: SlashCommand = {
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: `✅ Successfully authenticated with MCP server '${serverName}'!`,
|
text: t(
|
||||||
|
"Successfully authenticated and refreshed tools for '{{name}}'.",
|
||||||
|
{
|
||||||
|
name: serverName,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -122,7 +135,9 @@ const authCommand: SlashCommand = {
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: `Re-discovering tools from '${serverName}'...`,
|
text: t("Re-discovering tools from '{{name}}'...", {
|
||||||
|
name: serverName,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -140,13 +155,24 @@ const authCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
messageType: 'info',
|
||||||
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
|
content: t(
|
||||||
|
"Successfully authenticated and refreshed tools for '{{name}}'.",
|
||||||
|
{
|
||||||
|
name: serverName,
|
||||||
|
},
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 {
|
} finally {
|
||||||
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
||||||
@@ -165,7 +191,9 @@ const authCommand: SlashCommand = {
|
|||||||
|
|
||||||
const listCommand: SlashCommand = {
|
const listCommand: SlashCommand = {
|
||||||
name: 'list',
|
name: 'list',
|
||||||
description: 'List configured MCP servers and tools',
|
get description() {
|
||||||
|
return t('List configured MCP servers and tools');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -176,7 +204,7 @@ const listCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Config not loaded.',
|
content: t('Config not loaded.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +213,7 @@ const listCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 = {
|
const refreshCommand: SlashCommand = {
|
||||||
name: 'refresh',
|
name: 'refresh',
|
||||||
description: 'Restarts MCP servers.',
|
get description() {
|
||||||
|
return t('Restarts MCP servers.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -286,7 +316,7 @@ const refreshCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Config not loaded.',
|
content: t('Config not loaded.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,14 +325,14 @@ const refreshCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Could not retrieve tool registry.',
|
content: t('Could not retrieve tool registry.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text: 'Restarting MCP servers...',
|
text: t('Restarting MCP servers...'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -324,8 +354,11 @@ const refreshCommand: SlashCommand = {
|
|||||||
|
|
||||||
export const mcpCommand: SlashCommand = {
|
export const mcpCommand: SlashCommand = {
|
||||||
name: 'mcp',
|
name: 'mcp',
|
||||||
description:
|
get description() {
|
||||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
return t(
|
||||||
|
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||||
|
);
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
subCommands: [listCommand, authCommand, refreshCommand],
|
subCommands: [listCommand, authCommand, refreshCommand],
|
||||||
// Default action when no subcommand is provided
|
// Default action when no subcommand is provided
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ import fs from 'fs/promises';
|
|||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const memoryCommand: SlashCommand = {
|
export const memoryCommand: SlashCommand = {
|
||||||
name: 'memory',
|
name: 'memory',
|
||||||
description: 'Commands for interacting with memory.',
|
get description() {
|
||||||
|
return t('Commands for interacting with memory.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
{
|
||||||
name: 'show',
|
name: 'show',
|
||||||
description: 'Show the current memory contents.',
|
get description() {
|
||||||
|
return t('Show the current memory contents.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const memoryContent = context.services.config?.getUserMemory() || '';
|
const memoryContent = context.services.config?.getUserMemory() || '';
|
||||||
@@ -31,8 +36,8 @@ export const memoryCommand: SlashCommand = {
|
|||||||
|
|
||||||
const messageContent =
|
const messageContent =
|
||||||
memoryContent.length > 0
|
memoryContent.length > 0
|
||||||
? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
|
? `${t('Current memory content from {{count}} file(s):', { count: String(fileCount) })}\n\n---\n${memoryContent}\n---`
|
||||||
: 'Memory is currently empty.';
|
: t('Memory is currently empty.');
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
@@ -45,7 +50,9 @@ export const memoryCommand: SlashCommand = {
|
|||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
{
|
||||||
name: '--project',
|
name: '--project',
|
||||||
description: 'Show project-level memory contents.',
|
get description() {
|
||||||
|
return t('Show project-level memory contents.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
try {
|
try {
|
||||||
@@ -57,8 +64,14 @@ export const memoryCommand: SlashCommand = {
|
|||||||
|
|
||||||
const messageContent =
|
const messageContent =
|
||||||
memoryContent.trim().length > 0
|
memoryContent.trim().length > 0
|
||||||
? `Project memory content from ${projectMemoryPath}:\n\n---\n${memoryContent}\n---`
|
? t(
|
||||||
: 'Project memory is currently empty.';
|
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
|
||||||
|
{
|
||||||
|
path: projectMemoryPath,
|
||||||
|
content: memoryContent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: t('Project memory is currently empty.');
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
@@ -71,7 +84,9 @@ export const memoryCommand: SlashCommand = {
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -80,7 +95,9 @@ export const memoryCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '--global',
|
name: '--global',
|
||||||
description: 'Show global memory contents.',
|
get description() {
|
||||||
|
return t('Show global memory contents.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
try {
|
try {
|
||||||
@@ -96,8 +113,10 @@ export const memoryCommand: SlashCommand = {
|
|||||||
|
|
||||||
const messageContent =
|
const messageContent =
|
||||||
globalMemoryContent.trim().length > 0
|
globalMemoryContent.trim().length > 0
|
||||||
? `Global memory content:\n\n---\n${globalMemoryContent}\n---`
|
? t('Global memory content:\n\n---\n{{content}}\n---', {
|
||||||
: 'Global memory is currently empty.';
|
content: globalMemoryContent,
|
||||||
|
})
|
||||||
|
: t('Global memory is currently empty.');
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
@@ -110,7 +129,9 @@ export const memoryCommand: SlashCommand = {
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -121,16 +142,20 @@ export const memoryCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'add',
|
name: 'add',
|
||||||
description:
|
get description() {
|
||||||
'Add content to the memory. Use --global for global memory or --project for project memory.',
|
return t(
|
||||||
|
'Add content to the memory. Use --global for global memory or --project for project memory.',
|
||||||
|
);
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (context, args): SlashCommandActionReturn | void => {
|
action: (context, args): SlashCommandActionReturn | void => {
|
||||||
if (!args || args.trim() === '') {
|
if (!args || args.trim() === '') {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content:
|
content: t(
|
||||||
'Usage: /memory add [--global|--project] <text to remember>',
|
'Usage: /memory add [--global|--project] <text to remember>',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +175,9 @@ export const memoryCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content:
|
content: t(
|
||||||
'Usage: /memory add [--global|--project] <text to remember>',
|
'Usage: /memory add [--global|--project] <text to remember>',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// No scope specified, will be handled by the tool
|
// No scope specified, will be handled by the tool
|
||||||
@@ -162,8 +188,9 @@ export const memoryCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content:
|
content: t(
|
||||||
'Usage: /memory add [--global|--project] <text to remember>',
|
'Usage: /memory add [--global|--project] <text to remember>',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +198,10 @@ export const memoryCommand: SlashCommand = {
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -185,21 +215,25 @@ export const memoryCommand: SlashCommand = {
|
|||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
{
|
||||||
name: '--project',
|
name: '--project',
|
||||||
description: 'Add content to project-level memory.',
|
get description() {
|
||||||
|
return t('Add content to project-level memory.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (context, args): SlashCommandActionReturn | void => {
|
action: (context, args): SlashCommandActionReturn | void => {
|
||||||
if (!args || args.trim() === '') {
|
if (!args || args.trim() === '') {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Usage: /memory add --project <text to remember>',
|
content: t('Usage: /memory add --project <text to remember>'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -213,21 +247,25 @@ export const memoryCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '--global',
|
name: '--global',
|
||||||
description: 'Add content to global memory.',
|
get description() {
|
||||||
|
return t('Add content to global memory.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (context, args): SlashCommandActionReturn | void => {
|
action: (context, args): SlashCommandActionReturn | void => {
|
||||||
if (!args || args.trim() === '') {
|
if (!args || args.trim() === '') {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Usage: /memory add --global <text to remember>',
|
content: t('Usage: /memory add --global <text to remember>'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -243,13 +281,15 @@ export const memoryCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'refresh',
|
name: 'refresh',
|
||||||
description: 'Refresh the memory from the source.',
|
get description() {
|
||||||
|
return t('Refresh the memory from the source.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: 'Refreshing memory from source files...',
|
text: t('Refreshing memory from source files...'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import type {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const modelCommand: SlashCommand = {
|
export const modelCommand: SlashCommand = {
|
||||||
name: 'model',
|
name: 'model',
|
||||||
description: 'Switch the model for this session',
|
get description() {
|
||||||
|
return t('Switch the model for this session');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -36,7 +39,7 @@ export const modelCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Content generator configuration not available.',
|
content: t('Content generator configuration not available.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +48,7 @@ export const modelCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Authentication type not available.',
|
content: t('Authentication type not available.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +58,12 @@ export const modelCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: `No models available for the current authentication type (${authType}).`,
|
content: t(
|
||||||
|
'No models available for the current authentication type ({{authType}}).',
|
||||||
|
{
|
||||||
|
authType,
|
||||||
|
},
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const permissionsCommand: SlashCommand = {
|
export const permissionsCommand: SlashCommand = {
|
||||||
name: 'permissions',
|
name: 'permissions',
|
||||||
description: 'Manage folder trust settings',
|
get description() {
|
||||||
|
return t('Manage folder trust settings');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (): OpenDialogActionReturn => ({
|
action: (): OpenDialogActionReturn => ({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
import { formatDuration } from '../utils/formatters.js';
|
import { formatDuration } from '../utils/formatters.js';
|
||||||
import { CommandKind, type SlashCommand } from './types.js';
|
import { CommandKind, type SlashCommand } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const quitConfirmCommand: SlashCommand = {
|
export const quitConfirmCommand: SlashCommand = {
|
||||||
name: 'quit-confirm',
|
name: 'quit-confirm',
|
||||||
description: 'Show quit confirmation dialog',
|
get description() {
|
||||||
|
return t('Show quit confirmation dialog');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (context) => {
|
action: (context) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -37,7 +40,9 @@ export const quitConfirmCommand: SlashCommand = {
|
|||||||
export const quitCommand: SlashCommand = {
|
export const quitCommand: SlashCommand = {
|
||||||
name: 'quit',
|
name: 'quit',
|
||||||
altNames: ['exit'],
|
altNames: ['exit'],
|
||||||
description: 'exit the cli',
|
get description() {
|
||||||
|
return t('exit the cli');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (context) => {
|
action: (context) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const settingsCommand: SlashCommand = {
|
export const settingsCommand: SlashCommand = {
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
description: 'View and edit Qwen Code settings',
|
get description() {
|
||||||
|
return t('View and edit Qwen Code settings');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (_context, _args): OpenDialogActionReturn => ({
|
action: (_context, _args): OpenDialogActionReturn => ({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const GITHUB_WORKFLOW_PATHS = [
|
export const GITHUB_WORKFLOW_PATHS = [
|
||||||
'gemini-dispatch/gemini-dispatch.yml',
|
'gemini-dispatch/gemini-dispatch.yml',
|
||||||
@@ -91,7 +92,9 @@ export async function updateGitignore(gitRepoRoot: string): Promise<void> {
|
|||||||
|
|
||||||
export const setupGithubCommand: SlashCommand = {
|
export const setupGithubCommand: SlashCommand = {
|
||||||
name: 'setup-github',
|
name: 'setup-github',
|
||||||
description: 'Set up GitHub Actions',
|
get description() {
|
||||||
|
return t('Set up GitHub Actions');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (
|
action: async (
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import {
|
|||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
CommandKind,
|
CommandKind,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const statsCommand: SlashCommand = {
|
export const statsCommand: SlashCommand = {
|
||||||
name: 'stats',
|
name: 'stats',
|
||||||
altNames: ['usage'],
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (context: CommandContext) => {
|
action: (context: CommandContext) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -25,7 +28,7 @@ export const statsCommand: SlashCommand = {
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Session start time is unavailable, cannot calculate stats.',
|
text: t('Session start time is unavailable, cannot calculate stats.'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -43,7 +46,9 @@ export const statsCommand: SlashCommand = {
|
|||||||
subCommands: [
|
subCommands: [
|
||||||
{
|
{
|
||||||
name: 'model',
|
name: 'model',
|
||||||
description: 'Show model-specific usage statistics.',
|
get description() {
|
||||||
|
return t('Show model-specific usage statistics.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (context: CommandContext) => {
|
action: (context: CommandContext) => {
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
@@ -56,7 +61,9 @@ export const statsCommand: SlashCommand = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tools',
|
name: 'tools',
|
||||||
description: 'Show tool-specific usage statistics.',
|
get description() {
|
||||||
|
return t('Show tool-specific usage statistics.');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (context: CommandContext) => {
|
action: (context: CommandContext) => {
|
||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { getProjectSummaryPrompt } from '@qwen-code/qwen-code-core';
|
import { getProjectSummaryPrompt } from '@qwen-code/qwen-code-core';
|
||||||
import type { HistoryItemSummary } from '../types.js';
|
import type { HistoryItemSummary } from '../types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const summaryCommand: SlashCommand = {
|
export const summaryCommand: SlashCommand = {
|
||||||
name: 'summary',
|
name: 'summary',
|
||||||
description:
|
get description() {
|
||||||
'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md',
|
return t(
|
||||||
|
'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md',
|
||||||
|
);
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context): Promise<SlashCommandActionReturn> => {
|
action: async (context): Promise<SlashCommandActionReturn> => {
|
||||||
const { config } = context.services;
|
const { config } = context.services;
|
||||||
@@ -26,7 +30,7 @@ export const summaryCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Config not loaded.',
|
content: t('Config not loaded.'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ export const summaryCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'error' as const,
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content:
|
content: t(
|
||||||
'Already generating summary, wait for previous request to complete',
|
'Already generating summary, wait for previous request to complete',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +72,7 @@ export const summaryCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'info',
|
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(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: 'error' as const,
|
type: 'error' as const,
|
||||||
text: `❌ Failed to generate project context summary: ${
|
text: `❌ ${t(
|
||||||
error instanceof Error ? error.message : String(error)
|
'Failed to generate project context summary: {{error}}',
|
||||||
}`,
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
)}`,
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
@@ -181,9 +191,9 @@ export const summaryCommand: SlashCommand = {
|
|||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: `Failed to generate project context summary: ${
|
content: t('Failed to generate project context summary: {{error}}', {
|
||||||
error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error),
|
||||||
}`,
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type { MessageActionReturn, SlashCommand } from './types.js';
|
import type { MessageActionReturn, SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
import { terminalSetup } from '../utils/terminalSetup.js';
|
import { terminalSetup } from '../utils/terminalSetup.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command to configure terminal keybindings for multiline input support.
|
* Command to configure terminal keybindings for multiline input support.
|
||||||
@@ -16,8 +17,11 @@ import { terminalSetup } from '../utils/terminalSetup.js';
|
|||||||
*/
|
*/
|
||||||
export const terminalSetupCommand: SlashCommand = {
|
export const terminalSetupCommand: SlashCommand = {
|
||||||
name: 'terminal-setup',
|
name: 'terminal-setup',
|
||||||
description:
|
get description() {
|
||||||
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
|
return t(
|
||||||
|
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
|
||||||
|
);
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
|
|
||||||
action: async (): Promise<MessageActionReturn> => {
|
action: async (): Promise<MessageActionReturn> => {
|
||||||
@@ -27,7 +31,8 @@ export const terminalSetupCommand: SlashCommand = {
|
|||||||
let content = result.message;
|
let content = result.message;
|
||||||
if (result.requiresRestart) {
|
if (result.requiresRestart) {
|
||||||
content +=
|
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 {
|
return {
|
||||||
@@ -38,7 +43,9 @@ export const terminalSetupCommand: SlashCommand = {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
content: `Failed to configure terminal: ${error}`,
|
content: t('Failed to configure terminal: {{error}}', {
|
||||||
|
error: String(error),
|
||||||
|
}),
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const themeCommand: SlashCommand = {
|
export const themeCommand: SlashCommand = {
|
||||||
name: 'theme',
|
name: 'theme',
|
||||||
description: 'change the theme',
|
get description() {
|
||||||
|
return t('change the theme');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: (_context, _args): OpenDialogActionReturn => ({
|
action: (_context, _args): OpenDialogActionReturn => ({
|
||||||
type: 'dialog',
|
type: 'dialog',
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
CommandKind,
|
CommandKind,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { MessageType, type HistoryItemToolsList } from '../types.js';
|
import { MessageType, type HistoryItemToolsList } from '../types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const toolsCommand: SlashCommand = {
|
export const toolsCommand: SlashCommand = {
|
||||||
name: 'tools',
|
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,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||||
const subCommand = args?.trim();
|
const subCommand = args?.trim();
|
||||||
@@ -29,7 +32,7 @@ export const toolsCommand: SlashCommand = {
|
|||||||
context.ui.addItem(
|
context.ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: 'Could not retrieve tool registry.',
|
text: t('Could not retrieve tool registry.'),
|
||||||
},
|
},
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
import type { SlashCommand } from './types.js';
|
import type { SlashCommand } from './types.js';
|
||||||
import { CommandKind } from './types.js';
|
import { CommandKind } from './types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const vimCommand: SlashCommand = {
|
export const vimCommand: SlashCommand = {
|
||||||
name: 'vim',
|
name: 'vim',
|
||||||
description: 'toggle vim mode on/off',
|
get description() {
|
||||||
|
return t('toggle vim mode on/off');
|
||||||
|
},
|
||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: async (context, _args) => {
|
action: async (context, _args) => {
|
||||||
const newVimState = await context.ui.toggleVimEnabled();
|
const newVimState = await context.ui.toggleVimEnabled();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getFieldValue,
|
getFieldValue,
|
||||||
type SystemInfoField,
|
type SystemInfoField,
|
||||||
} from '../../utils/systemInfoFields.js';
|
} from '../../utils/systemInfoFields.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
type AboutBoxProps = ExtendedSystemInfo;
|
type AboutBoxProps = ExtendedSystemInfo;
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
About Qwen Code
|
{t('About Qwen Code')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{fields.map((field: SystemInfoField) => (
|
{fields.map((field: SystemInfoField) => (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SettingScope } from '../../config/settings.js';
|
|||||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface ApprovalModeDialogProps {
|
interface ApprovalModeDialogProps {
|
||||||
/** Callback function when an approval mode is selected */
|
/** Callback function when an approval mode is selected */
|
||||||
@@ -33,15 +34,15 @@ interface ApprovalModeDialogProps {
|
|||||||
const formatModeDescription = (mode: ApprovalMode): string => {
|
const formatModeDescription = (mode: ApprovalMode): string => {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case ApprovalMode.PLAN:
|
case ApprovalMode.PLAN:
|
||||||
return 'Analyze only, do not modify files or execute commands';
|
return t('Analyze only, do not modify files or execute commands');
|
||||||
case ApprovalMode.DEFAULT:
|
case ApprovalMode.DEFAULT:
|
||||||
return 'Require approval for file edits or shell commands';
|
return t('Require approval for file edits or shell commands');
|
||||||
case ApprovalMode.AUTO_EDIT:
|
case ApprovalMode.AUTO_EDIT:
|
||||||
return 'Automatically approve file edits';
|
return t('Automatically approve file edits');
|
||||||
case ApprovalMode.YOLO:
|
case ApprovalMode.YOLO:
|
||||||
return 'Automatically approve all tools';
|
return t('Automatically approve all tools');
|
||||||
default:
|
default:
|
||||||
return `${mode} mode`;
|
return t('{{mode}} mode', { mode });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,7 +135,8 @@ export function ApprovalModeDialog({
|
|||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
{/* Approval Mode Selection */}
|
{/* Approval Mode Selection */}
|
||||||
<Text bold={focusSection === 'mode'} wrap="truncate">
|
<Text bold={focusSection === 'mode'} wrap="truncate">
|
||||||
{focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '}
|
{focusSection === 'mode' ? '> ' : ' '}
|
||||||
|
{t('Approval Mode')}{' '}
|
||||||
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
@@ -167,15 +169,17 @@ export function ApprovalModeDialog({
|
|||||||
{showWorkspacePriorityWarning && (
|
{showWorkspacePriorityWarning && (
|
||||||
<>
|
<>
|
||||||
<Text color={theme.status.warning} wrap="wrap">
|
<Text color={theme.status.warning} wrap="wrap">
|
||||||
⚠ Workspace approval mode exists and takes priority. User-level
|
⚠{' '}
|
||||||
change will have no effect.
|
{t(
|
||||||
|
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
(Use Enter to select, Tab to change focus)
|
{t('(Use Enter to select, Tab to change focus)')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface AutoAcceptIndicatorProps {
|
interface AutoAcceptIndicatorProps {
|
||||||
approvalMode: ApprovalMode;
|
approvalMode: ApprovalMode;
|
||||||
@@ -23,18 +24,18 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
|
|||||||
switch (approvalMode) {
|
switch (approvalMode) {
|
||||||
case ApprovalMode.PLAN:
|
case ApprovalMode.PLAN:
|
||||||
textColor = theme.status.success;
|
textColor = theme.status.success;
|
||||||
textContent = 'plan mode';
|
textContent = t('plan mode');
|
||||||
subText = ' (shift + tab to cycle)';
|
subText = ` ${t('(shift + tab to cycle)')}`;
|
||||||
break;
|
break;
|
||||||
case ApprovalMode.AUTO_EDIT:
|
case ApprovalMode.AUTO_EDIT:
|
||||||
textColor = theme.status.warning;
|
textColor = theme.status.warning;
|
||||||
textContent = 'auto-accept edits';
|
textContent = t('auto-accept edits');
|
||||||
subText = ' (shift + tab to cycle)';
|
subText = ` ${t('(shift + tab to cycle)')}`;
|
||||||
break;
|
break;
|
||||||
case ApprovalMode.YOLO:
|
case ApprovalMode.YOLO:
|
||||||
textColor = theme.status.error;
|
textColor = theme.status.error;
|
||||||
textContent = 'YOLO mode';
|
textContent = t('YOLO mode');
|
||||||
subText = ' (shift + tab to cycle)';
|
subText = ` ${t('(shift + tab to cycle)')}`;
|
||||||
break;
|
break;
|
||||||
case ApprovalMode.DEFAULT:
|
case ApprovalMode.DEFAULT:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
|||||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const Composer = () => {
|
export const Composer = () => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
@@ -86,14 +87,16 @@ export const Composer = () => {
|
|||||||
)}
|
)}
|
||||||
{uiState.ctrlCPressedOnce ? (
|
{uiState.ctrlCPressedOnce ? (
|
||||||
<Text color={theme.status.warning}>
|
<Text color={theme.status.warning}>
|
||||||
Press Ctrl+C again to exit.
|
{t('Press Ctrl+C again to exit.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : uiState.ctrlDPressedOnce ? (
|
) : uiState.ctrlDPressedOnce ? (
|
||||||
<Text color={theme.status.warning}>
|
<Text color={theme.status.warning}>
|
||||||
Press Ctrl+D again to exit.
|
{t('Press Ctrl+D again to exit.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : uiState.showEscapePrompt ? (
|
) : uiState.showEscapePrompt ? (
|
||||||
<Text color={theme.text.secondary}>Press Esc again to clear.</Text>
|
<Text color={theme.text.secondary}>
|
||||||
|
{t('Press Esc again to clear.')}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
!settings.merged.ui?.hideContextSummary && (
|
!settings.merged.ui?.hideContextSummary && (
|
||||||
<ContextSummaryDisplay
|
<ContextSummaryDisplay
|
||||||
@@ -151,8 +154,8 @@ export const Composer = () => {
|
|||||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||||
placeholder={
|
placeholder={
|
||||||
vimEnabled
|
vimEnabled
|
||||||
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
|
? ' ' + t("Press 'i' for INSERT mode and 'Esc' for NORMAL mode.")
|
||||||
: ' Type your message or @path/to/file'
|
: ' ' + t('Type your message or @path/to/file')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,15 +11,16 @@ import { useConfig } from '../contexts/ConfigContext.js';
|
|||||||
import { type McpClient, MCPServerStatus } from '@qwen-code/qwen-code-core';
|
import { type McpClient, MCPServerStatus } from '@qwen-code/qwen-code-core';
|
||||||
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
|
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export const ConfigInitDisplay = () => {
|
export const ConfigInitDisplay = () => {
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const [message, setMessage] = useState('Initializing...');
|
const [message, setMessage] = useState(t('Initializing...'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = (clients?: Map<string, McpClient>) => {
|
const onChange = (clients?: Map<string, McpClient>) => {
|
||||||
if (!clients || clients.size === 0) {
|
if (!clients || clients.size === 0) {
|
||||||
setMessage(`Initializing...`);
|
setMessage(t('Initializing...'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let connected = 0;
|
let connected = 0;
|
||||||
@@ -28,7 +29,12 @@ export const ConfigInitDisplay = () => {
|
|||||||
connected++;
|
connected++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMessage(`Connecting to MCP servers... (${connected}/${clients.size})`);
|
setMessage(
|
||||||
|
t('Connecting to MCP servers... ({{connected}}/{{total}})', {
|
||||||
|
connected: String(connected),
|
||||||
|
total: String(clients.size),
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
appEvents.on('mcp-client-update', onChange);
|
appEvents.on('mcp-client-update', onChange);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface ContextSummaryDisplayProps {
|
interface ContextSummaryDisplayProps {
|
||||||
geminiMdFileCount: number;
|
geminiMdFileCount: number;
|
||||||
@@ -50,9 +51,11 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
if (openFileCount === 0) {
|
if (openFileCount === 0) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return `${openFileCount} open file${
|
const fileText =
|
||||||
openFileCount > 1 ? 's' : ''
|
openFileCount === 1
|
||||||
} (ctrl+g to view)`;
|
? t('{{count}} open file', { count: String(openFileCount) })
|
||||||
|
: t('{{count}} open files', { count: String(openFileCount) });
|
||||||
|
return `${fileText} ${t('(ctrl+g to view)')}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const geminiMdText = (() => {
|
const geminiMdText = (() => {
|
||||||
@@ -61,9 +64,15 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
}
|
}
|
||||||
const allNamesTheSame = new Set(contextFileNames).size < 2;
|
const allNamesTheSame = new Set(contextFileNames).size < 2;
|
||||||
const name = allNamesTheSame ? contextFileNames[0] : 'context';
|
const name = allNamesTheSame ? contextFileNames[0] : 'context';
|
||||||
return `${geminiMdFileCount} ${name} file${
|
return geminiMdFileCount === 1
|
||||||
geminiMdFileCount > 1 ? 's' : ''
|
? t('{{count}} {{name}} file', {
|
||||||
}`;
|
count: String(geminiMdFileCount),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
: t('{{count}} {{name}} files', {
|
||||||
|
count: String(geminiMdFileCount),
|
||||||
|
name,
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const mcpText = (() => {
|
const mcpText = (() => {
|
||||||
@@ -73,15 +82,27 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (mcpServerCount > 0) {
|
if (mcpServerCount > 0) {
|
||||||
parts.push(
|
const serverText =
|
||||||
`${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`,
|
mcpServerCount === 1
|
||||||
);
|
? t('{{count}} MCP server', { count: String(mcpServerCount) })
|
||||||
|
: t('{{count}} MCP servers', { count: String(mcpServerCount) });
|
||||||
|
parts.push(serverText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blockedMcpServerCount > 0) {
|
if (blockedMcpServerCount > 0) {
|
||||||
let blockedText = `${blockedMcpServerCount} Blocked`;
|
let blockedText = t('{{count}} Blocked', {
|
||||||
|
count: String(blockedMcpServerCount),
|
||||||
|
});
|
||||||
if (mcpServerCount === 0) {
|
if (mcpServerCount === 0) {
|
||||||
blockedText += ` MCP server${blockedMcpServerCount > 1 ? 's' : ''}`;
|
const serverText =
|
||||||
|
blockedMcpServerCount === 1
|
||||||
|
? t('{{count}} MCP server', {
|
||||||
|
count: String(blockedMcpServerCount),
|
||||||
|
})
|
||||||
|
: t('{{count}} MCP servers', {
|
||||||
|
count: String(blockedMcpServerCount),
|
||||||
|
});
|
||||||
|
blockedText += ` ${serverText}`;
|
||||||
}
|
}
|
||||||
parts.push(blockedText);
|
parts.push(blockedText);
|
||||||
}
|
}
|
||||||
@@ -89,9 +110,9 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
// Add ctrl+t hint when MCP servers are available
|
// Add ctrl+t hint when MCP servers are available
|
||||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||||
if (showToolDescriptions) {
|
if (showToolDescriptions) {
|
||||||
text += ' (ctrl+t to toggle)';
|
text += ` ${t('(ctrl+t to toggle)')}`;
|
||||||
} else {
|
} else {
|
||||||
text += ' (ctrl+t to view)';
|
text += ` ${t('(ctrl+t to view)')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
@@ -102,7 +123,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
if (isNarrow) {
|
if (isNarrow) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text color={theme.text.secondary}>Using:</Text>
|
<Text color={theme.text.secondary}>{t('Using:')}</Text>
|
||||||
{summaryParts.map((part, index) => (
|
{summaryParts.map((part, index) => (
|
||||||
<Text key={index} color={theme.text.secondary}>
|
<Text key={index} color={theme.text.secondary}>
|
||||||
{' '}- {part}
|
{' '}- {part}
|
||||||
@@ -115,7 +136,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
Using: {summaryParts.join(' | ')}
|
{t('Using:')} {summaryParts.join(' | ')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { SettingScope } from '../../config/settings.js';
|
|||||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||||
import { isEditorAvailable } from '@qwen-code/qwen-code-core';
|
import { isEditorAvailable } from '@qwen-code/qwen-code-core';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface EditorDialogProps {
|
interface EditorDialogProps {
|
||||||
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
|
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
|
||||||
@@ -66,12 +67,16 @@ export function EditorSettingsDialog({
|
|||||||
|
|
||||||
const scopeItems = [
|
const scopeItems = [
|
||||||
{
|
{
|
||||||
label: 'User Settings',
|
get label() {
|
||||||
|
return t('User Settings');
|
||||||
|
},
|
||||||
value: SettingScope.User,
|
value: SettingScope.User,
|
||||||
key: SettingScope.User,
|
key: SettingScope.User,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Workspace Settings',
|
get label() {
|
||||||
|
return t('Workspace Settings');
|
||||||
|
},
|
||||||
value: SettingScope.Workspace,
|
value: SettingScope.Workspace,
|
||||||
key: SettingScope.Workspace,
|
key: SettingScope.Workspace,
|
||||||
},
|
},
|
||||||
@@ -145,7 +150,8 @@ export function EditorSettingsDialog({
|
|||||||
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text bold={focusedSection === 'scope'}>
|
<Text bold={focusedSection === 'scope'}>
|
||||||
{focusedSection === 'scope' ? '> ' : ' '}Apply To
|
{focusedSection === 'scope' ? '> ' : ' '}
|
||||||
|
{t('Apply To')}
|
||||||
</Text>
|
</Text>
|
||||||
<RadioButtonSelect
|
<RadioButtonSelect
|
||||||
items={scopeItems}
|
items={scopeItems}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { type SlashCommand, CommandKind } from '../commands/types.js';
|
import { type SlashCommand, CommandKind } from '../commands/types.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface Help {
|
interface Help {
|
||||||
commands: readonly SlashCommand[];
|
commands: readonly SlashCommand[];
|
||||||
@@ -23,46 +24,41 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
|||||||
>
|
>
|
||||||
{/* Basics */}
|
{/* Basics */}
|
||||||
<Text bold color={theme.text.primary}>
|
<Text bold color={theme.text.primary}>
|
||||||
Basics:
|
{t('Basics:')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Add context
|
{t('Add context')}
|
||||||
</Text>
|
</Text>
|
||||||
: Use{' '}
|
:{' '}
|
||||||
<Text bold color={theme.text.accent}>
|
{t(
|
||||||
@
|
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.',
|
||||||
</Text>{' '}
|
{
|
||||||
to specify files for context (e.g.,{' '}
|
symbol: t('@'),
|
||||||
<Text bold color={theme.text.accent}>
|
example: t('@src/myFile.ts'),
|
||||||
@src/myFile.ts
|
},
|
||||||
</Text>
|
)}
|
||||||
) to target specific files or folders.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Shell mode
|
{t('Shell mode')}
|
||||||
</Text>
|
</Text>
|
||||||
: Execute shell commands via{' '}
|
:{' '}
|
||||||
<Text bold color={theme.text.accent}>
|
{t(
|
||||||
!
|
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).',
|
||||||
</Text>{' '}
|
{
|
||||||
(e.g.,{' '}
|
symbol: t('!'),
|
||||||
<Text bold color={theme.text.accent}>
|
example1: t('!npm run start'),
|
||||||
!npm run start
|
example2: t('start server'),
|
||||||
</Text>
|
},
|
||||||
) or use natural language (e.g.{' '}
|
)}
|
||||||
<Text bold color={theme.text.accent}>
|
|
||||||
start server
|
|
||||||
</Text>
|
|
||||||
).
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
|
|
||||||
{/* Commands */}
|
{/* Commands */}
|
||||||
<Text bold color={theme.text.primary}>
|
<Text bold color={theme.text.primary}>
|
||||||
Commands:
|
{t('Commands:')}
|
||||||
</Text>
|
</Text>
|
||||||
{commands
|
{commands
|
||||||
.filter((command) => command.description && !command.hidden)
|
.filter((command) => command.description && !command.hidden)
|
||||||
@@ -97,81 +93,81 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
|||||||
{' '}
|
{' '}
|
||||||
!{' '}
|
!{' '}
|
||||||
</Text>
|
</Text>
|
||||||
- shell command
|
- {t('shell command')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text color={theme.text.secondary}>[MCP]</Text> - Model Context Protocol
|
<Text color={theme.text.secondary}>[MCP]</Text> -{' '}
|
||||||
command (from external servers)
|
{t('Model Context Protocol command (from external servers)')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
|
|
||||||
{/* Shortcuts */}
|
{/* Shortcuts */}
|
||||||
<Text bold color={theme.text.primary}>
|
<Text bold color={theme.text.primary}>
|
||||||
Keyboard Shortcuts:
|
{t('Keyboard Shortcuts:')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Alt+Left/Right
|
Alt+Left/Right
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
- Jump through words in the input
|
- {t('Jump through words in the input')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Ctrl+C
|
Ctrl+C
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
- Close dialogs, cancel requests, or quit application
|
- {t('Close dialogs, cancel requests, or quit application')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
|
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
|
-{' '}
|
||||||
{process.platform === 'linux'
|
{process.platform === 'linux'
|
||||||
? '- New line (Alt+Enter works for certain linux distros)'
|
? t('New line (Alt+Enter works for certain linux distros)')
|
||||||
: '- New line'}
|
: t('New line')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Ctrl+L
|
Ctrl+L
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
- Clear the screen
|
- {t('Clear the screen')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
|
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
- Open input in external editor
|
- {t('Open input in external editor')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Enter
|
Enter
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
- Send message
|
- {t('Send message')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Esc
|
Esc
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
- Cancel operation / Clear input (double press)
|
- {t('Cancel operation / Clear input (double press)')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Shift+Tab
|
Shift+Tab
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
- Cycle approval modes
|
- {t('Cycle approval modes')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Up/Down
|
Up/Down
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
- Cycle through your prompt history
|
- {t('Cycle through your prompt history')}
|
||||||
</Text>
|
</Text>
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
For a full list of shortcuts, see{' '}
|
{t('For a full list of shortcuts, see {{docPath}}', {
|
||||||
<Text bold color={theme.text.accent}>
|
docPath: t('docs/keyboard-shortcuts.md'),
|
||||||
docs/keyboard-shortcuts.md
|
})}
|
||||||
</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ describe('InputPrompt', () => {
|
|||||||
inputWidth: 80,
|
inputWidth: 80,
|
||||||
suggestionsWidth: 80,
|
suggestionsWidth: 80,
|
||||||
focus: true,
|
focus: true,
|
||||||
|
placeholder: ' Type your message or @path/to/file',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1950,7 +1951,7 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('expands and collapses long suggestion via Right/Left arrows', async () => {
|
it.skip('expands and collapses long suggestion via Right/Left arrows', async () => {
|
||||||
props.shellModeActive = false;
|
props.shellModeActive = false;
|
||||||
const longValue = 'l'.repeat(200);
|
const longValue = 'l'.repeat(200);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
parseInputForHighlighting,
|
parseInputForHighlighting,
|
||||||
buildSegmentsForVisualSlice,
|
buildSegmentsForVisualSlice,
|
||||||
} from '../utils/highlight.js';
|
} from '../utils/highlight.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
clipboardHasImage,
|
clipboardHasImage,
|
||||||
saveClipboardImage,
|
saveClipboardImage,
|
||||||
@@ -88,7 +89,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
config,
|
config,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
commandContext,
|
commandContext,
|
||||||
placeholder = ' Type your message or @path/to/file',
|
placeholder,
|
||||||
focus = true,
|
focus = true,
|
||||||
suggestionsWidth,
|
suggestionsWidth,
|
||||||
shellModeActive,
|
shellModeActive,
|
||||||
@@ -697,13 +698,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
let statusText = '';
|
let statusText = '';
|
||||||
if (shellModeActive) {
|
if (shellModeActive) {
|
||||||
statusColor = theme.ui.symbol;
|
statusColor = theme.ui.symbol;
|
||||||
statusText = 'Shell mode';
|
statusText = t('Shell mode');
|
||||||
} else if (showYoloStyling) {
|
} else if (showYoloStyling) {
|
||||||
statusColor = theme.status.error;
|
statusColor = theme.status.error;
|
||||||
statusText = 'YOLO mode';
|
statusText = t('YOLO mode');
|
||||||
} else if (showAutoAcceptStyling) {
|
} else if (showAutoAcceptStyling) {
|
||||||
statusColor = theme.status.warning;
|
statusColor = theme.status.warning;
|
||||||
statusText = 'Accepting edits';
|
statusText = t('Accepting edits');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
|||||||
import { formatDuration } from '../utils/formatters.js';
|
import { formatDuration } from '../utils/formatters.js';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface LoadingIndicatorProps {
|
interface LoadingIndicatorProps {
|
||||||
currentLoadingPhrase?: string;
|
currentLoadingPhrase?: string;
|
||||||
@@ -40,7 +41,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
|||||||
|
|
||||||
const cancelAndTimerContent =
|
const cancelAndTimerContent =
|
||||||
streamingState !== StreamingState.WaitingForConfirmation
|
streamingState !== StreamingState.WaitingForConfirmation
|
||||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
? t('(esc to cancel, {{time}})', {
|
||||||
|
time:
|
||||||
|
elapsedTime < 60
|
||||||
|
? `${elapsedTime}s`
|
||||||
|
: formatDuration(elapsedTime * 1000),
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getAvailableModelsForAuthType,
|
getAvailableModelsForAuthType,
|
||||||
MAINLINE_CODER,
|
MAINLINE_CODER,
|
||||||
} from '../models/availableModels.js';
|
} from '../models/availableModels.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface ModelDialogProps {
|
interface ModelDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -87,7 +88,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||||||
padding={1}
|
padding={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text bold>Select Model</Text>
|
<Text bold>{t('Select Model')}</Text>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<DescriptiveRadioButtonSelect
|
<DescriptiveRadioButtonSelect
|
||||||
items={MODEL_OPTIONS}
|
items={MODEL_OPTIONS}
|
||||||
@@ -97,7 +98,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={theme.text.secondary}>(Press Esc to close)</Text>
|
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../utils/computeStats.js';
|
} from '../utils/computeStats.js';
|
||||||
import type { ModelMetrics } from '../contexts/SessionContext.js';
|
import type { ModelMetrics } from '../contexts/SessionContext.js';
|
||||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
const METRIC_COL_WIDTH = 28;
|
const METRIC_COL_WIDTH = 28;
|
||||||
const MODEL_COL_WIDTH = 22;
|
const MODEL_COL_WIDTH = 22;
|
||||||
@@ -65,7 +66,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||||||
paddingX={2}
|
paddingX={2}
|
||||||
>
|
>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
No API calls have been made in this session.
|
{t('No API calls have been made in this session.')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -94,7 +95,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||||||
paddingX={2}
|
paddingX={2}
|
||||||
>
|
>
|
||||||
<Text bold color={theme.text.accent}>
|
<Text bold color={theme.text.accent}>
|
||||||
Model Stats For Nerds
|
{t('Model Stats For Nerds')}
|
||||||
</Text>
|
</Text>
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||||||
<Box>
|
<Box>
|
||||||
<Box width={METRIC_COL_WIDTH}>
|
<Box width={METRIC_COL_WIDTH}>
|
||||||
<Text bold color={theme.text.primary}>
|
<Text bold color={theme.text.primary}>
|
||||||
Metric
|
{t('Metric')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{modelNames.map((name) => (
|
{modelNames.map((name) => (
|
||||||
@@ -125,13 +126,13 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* API Section */}
|
{/* API Section */}
|
||||||
<StatRow title="API" values={[]} isSection />
|
<StatRow title={t('API')} values={[]} isSection />
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Requests"
|
title={t('Requests')}
|
||||||
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
|
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
|
||||||
/>
|
/>
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Errors"
|
title={t('Errors')}
|
||||||
values={getModelValues((m) => {
|
values={getModelValues((m) => {
|
||||||
const errorRate = calculateErrorRate(m);
|
const errorRate = calculateErrorRate(m);
|
||||||
return (
|
return (
|
||||||
@@ -146,7 +147,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Avg Latency"
|
title={t('Avg Latency')}
|
||||||
values={getModelValues((m) => {
|
values={getModelValues((m) => {
|
||||||
const avgLatency = calculateAverageLatency(m);
|
const avgLatency = calculateAverageLatency(m);
|
||||||
return formatDuration(avgLatency);
|
return formatDuration(avgLatency);
|
||||||
@@ -156,9 +157,9 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
|
|
||||||
{/* Tokens Section */}
|
{/* Tokens Section */}
|
||||||
<StatRow title="Tokens" values={[]} isSection />
|
<StatRow title={t('Tokens')} values={[]} isSection />
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Total"
|
title={t('Total')}
|
||||||
values={getModelValues((m) => (
|
values={getModelValues((m) => (
|
||||||
<Text color={theme.status.warning}>
|
<Text color={theme.status.warning}>
|
||||||
{m.tokens.total.toLocaleString()}
|
{m.tokens.total.toLocaleString()}
|
||||||
@@ -166,13 +167,13 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Prompt"
|
title={t('Prompt')}
|
||||||
isSubtle
|
isSubtle
|
||||||
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
|
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
|
||||||
/>
|
/>
|
||||||
{hasCached && (
|
{hasCached && (
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Cached"
|
title={t('Cached')}
|
||||||
isSubtle
|
isSubtle
|
||||||
values={getModelValues((m) => {
|
values={getModelValues((m) => {
|
||||||
const cacheHitRate = calculateCacheHitRate(m);
|
const cacheHitRate = calculateCacheHitRate(m);
|
||||||
@@ -186,20 +187,20 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{hasThoughts && (
|
{hasThoughts && (
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Thoughts"
|
title={t('Thoughts')}
|
||||||
isSubtle
|
isSubtle
|
||||||
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
|
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasTool && (
|
{hasTool && (
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Tool"
|
title={t('Tool')}
|
||||||
isSubtle
|
isSubtle
|
||||||
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
|
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StatRow
|
<StatRow
|
||||||
title="Output"
|
title={t('Output')}
|
||||||
isSubtle
|
isSubtle
|
||||||
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
|
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface OpenAIKeyPromptProps {
|
interface OpenAIKeyPromptProps {
|
||||||
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
|
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
|
||||||
@@ -64,9 +65,11 @@ export function OpenAIKeyPrompt({
|
|||||||
const errorMessage = error.errors
|
const errorMessage = error.errors
|
||||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
setValidationError(`Invalid credentials: ${errorMessage}`);
|
setValidationError(
|
||||||
|
t('Invalid credentials: {{errorMessage}}', { errorMessage }),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setValidationError('Failed to validate credentials');
|
setValidationError(t('Failed to validate credentials'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -205,7 +208,7 @@ export function OpenAIKeyPrompt({
|
|||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text bold color={Colors.AccentBlue}>
|
<Text bold color={Colors.AccentBlue}>
|
||||||
OpenAI Configuration Required
|
{t('OpenAI Configuration Required')}
|
||||||
</Text>
|
</Text>
|
||||||
{validationError && (
|
{validationError && (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
@@ -214,7 +217,9 @@ export function OpenAIKeyPrompt({
|
|||||||
)}
|
)}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>
|
<Text>
|
||||||
Please enter your OpenAI configuration. You can get an API key from{' '}
|
{t(
|
||||||
|
'Please enter your OpenAI configuration. You can get an API key from',
|
||||||
|
)}{' '}
|
||||||
<Text color={Colors.AccentBlue}>
|
<Text color={Colors.AccentBlue}>
|
||||||
https://bailian.console.aliyun.com/?tab=model#/api-key
|
https://bailian.console.aliyun.com/?tab=model#/api-key
|
||||||
</Text>
|
</Text>
|
||||||
@@ -225,7 +230,7 @@ export function OpenAIKeyPrompt({
|
|||||||
<Text
|
<Text
|
||||||
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
|
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
|
||||||
>
|
>
|
||||||
API Key:
|
{t('API Key:')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
@@ -240,7 +245,7 @@ export function OpenAIKeyPrompt({
|
|||||||
<Text
|
<Text
|
||||||
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
|
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
|
||||||
>
|
>
|
||||||
Base URL:
|
{t('Base URL:')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
@@ -255,7 +260,7 @@ export function OpenAIKeyPrompt({
|
|||||||
<Text
|
<Text
|
||||||
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
|
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
|
||||||
>
|
>
|
||||||
Model:
|
{t('Model:')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
@@ -267,7 +272,7 @@ export function OpenAIKeyPrompt({
|
|||||||
</Box>
|
</Box>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={Colors.Gray}>
|
<Text color={Colors.Gray}>
|
||||||
Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel
|
{t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface ProQuotaDialogProps {
|
interface ProQuotaDialogProps {
|
||||||
failedModel: string;
|
failedModel: string;
|
||||||
@@ -22,12 +23,12 @@ export function ProQuotaDialog({
|
|||||||
}: ProQuotaDialogProps): React.JSX.Element {
|
}: ProQuotaDialogProps): React.JSX.Element {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: 'Change auth (executes the /auth command)',
|
label: t('Change auth (executes the /auth command)'),
|
||||||
value: 'auth' as const,
|
value: 'auth' as const,
|
||||||
key: 'auth',
|
key: 'auth',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `Continue with ${fallbackModel}`,
|
label: t('Continue with {{model}}', { model: fallbackModel }),
|
||||||
value: 'continue' as const,
|
value: 'continue' as const,
|
||||||
key: 'continue',
|
key: 'continue',
|
||||||
},
|
},
|
||||||
@@ -40,7 +41,7 @@ export function ProQuotaDialog({
|
|||||||
return (
|
return (
|
||||||
<Box borderStyle="round" flexDirection="column" paddingX={1}>
|
<Box borderStyle="round" flexDirection="column" paddingX={1}>
|
||||||
<Text bold color={theme.status.warning}>
|
<Text bold color={theme.status.warning}>
|
||||||
Pro quota limit reached for {failedModel}.
|
{t('Pro quota limit reached for {{model}}.', { model: failedModel })}
|
||||||
</Text>
|
</Text>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<RadioButtonSelect
|
<RadioButtonSelect
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type RadioSelectItem,
|
type RadioSelectItem,
|
||||||
} from './shared/RadioButtonSelect.js';
|
} from './shared/RadioButtonSelect.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export enum QuitChoice {
|
export enum QuitChoice {
|
||||||
CANCEL = 'cancel',
|
CANCEL = 'cancel',
|
||||||
@@ -39,22 +40,22 @@ export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
|
|||||||
const options: Array<RadioSelectItem<QuitChoice>> = [
|
const options: Array<RadioSelectItem<QuitChoice>> = [
|
||||||
{
|
{
|
||||||
key: 'quit',
|
key: 'quit',
|
||||||
label: 'Quit immediately (/quit)',
|
label: t('Quit immediately (/quit)'),
|
||||||
value: QuitChoice.QUIT,
|
value: QuitChoice.QUIT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'summary-and-quit',
|
key: 'summary-and-quit',
|
||||||
label: 'Generate summary and quit (/summary)',
|
label: t('Generate summary and quit (/summary)'),
|
||||||
value: QuitChoice.SUMMARY_AND_QUIT,
|
value: QuitChoice.SUMMARY_AND_QUIT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'save-and-quit',
|
key: 'save-and-quit',
|
||||||
label: 'Save conversation and quit (/chat save)',
|
label: t('Save conversation and quit (/chat save)'),
|
||||||
value: QuitChoice.SAVE_AND_QUIT,
|
value: QuitChoice.SAVE_AND_QUIT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'cancel',
|
key: 'cancel',
|
||||||
label: 'Cancel (stay in application)',
|
label: t('Cancel (stay in application)'),
|
||||||
value: QuitChoice.CANCEL,
|
value: QuitChoice.CANCEL,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -69,7 +70,7 @@ export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
|
|||||||
marginLeft={1}
|
marginLeft={1}
|
||||||
>
|
>
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text>What would you like to do before exiting?</Text>
|
<Text>{t('What would you like to do before exiting?')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
|
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import qrcode from 'qrcode-terminal';
|
|||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface QwenOAuthProgressProps {
|
interface QwenOAuthProgressProps {
|
||||||
onTimeout: () => void;
|
onTimeout: () => void;
|
||||||
@@ -52,11 +53,11 @@ function QrCodeDisplay({
|
|||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text bold color={Colors.AccentBlue}>
|
<Text bold color={Colors.AccentBlue}>
|
||||||
Qwen OAuth Authentication
|
{t('Qwen OAuth Authentication')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>Please visit this URL to authorize:</Text>
|
<Text>{t('Please visit this URL to authorize:')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Link url={verificationUrl} fallback={false}>
|
<Link url={verificationUrl} fallback={false}>
|
||||||
@@ -66,7 +67,7 @@ function QrCodeDisplay({
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>Or scan the QR code below:</Text>
|
<Text>{t('Or scan the QR code below:')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
@@ -103,15 +104,18 @@ function StatusDisplay({
|
|||||||
>
|
>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>
|
<Text>
|
||||||
<Spinner type="dots" /> Waiting for authorization{dots}
|
<Spinner type="dots" /> {t('Waiting for authorization')}
|
||||||
|
{dots}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1} justifyContent="space-between">
|
<Box marginTop={1} justifyContent="space-between">
|
||||||
<Text color={Colors.Gray}>
|
<Text color={Colors.Gray}>
|
||||||
Time remaining: {formatTime(timeRemaining)}
|
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||||
|
</Text>
|
||||||
|
<Text color={Colors.AccentPurple}>
|
||||||
|
{t('(Press ESC or CTRL+C to cancel)')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={Colors.AccentPurple}>(Press ESC or CTRL+C to cancel)</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -215,19 +219,24 @@ export function QwenOAuthProgress({
|
|||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text bold color={Colors.AccentRed}>
|
<Text bold color={Colors.AccentRed}>
|
||||||
Qwen OAuth Authentication Timeout
|
{t('Qwen OAuth Authentication Timeout')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text>
|
<Text>
|
||||||
{authMessage ||
|
{authMessage ||
|
||||||
`OAuth token expired (over ${defaultTimeout} seconds). Please select authentication method again.`}
|
t(
|
||||||
|
'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.',
|
||||||
|
{
|
||||||
|
seconds: defaultTimeout.toString(),
|
||||||
|
},
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={Colors.Gray}>
|
<Text color={Colors.Gray}>
|
||||||
Press any key to return to authentication type selection.
|
{t('Press any key to return to authentication type selection.')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -275,16 +284,17 @@ export function QwenOAuthProgress({
|
|||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Text>
|
<Text>
|
||||||
<Spinner type="dots" /> Waiting for Qwen OAuth authentication...
|
<Spinner type="dots" />
|
||||||
|
{t('Waiting for Qwen OAuth authentication...')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box marginTop={1} justifyContent="space-between">
|
<Box marginTop={1} justifyContent="space-between">
|
||||||
<Text color={Colors.Gray}>
|
<Text color={Colors.Gray}>
|
||||||
Time remaining: {Math.floor(timeRemaining / 60)}:
|
{t('Time remaining:')} {Math.floor(timeRemaining / 60)}:
|
||||||
{(timeRemaining % 60).toString().padStart(2, '0')}
|
{(timeRemaining % 60).toString().padStart(2, '0')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={Colors.AccentPurple}>
|
<Text color={Colors.AccentPurple}>
|
||||||
(Press ESC or CTRL+C to cancel)
|
{t('(Press ESC or CTRL+C to cancel)')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { StatsDisplay } from './StatsDisplay.js';
|
import { StatsDisplay } from './StatsDisplay.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
interface SessionSummaryDisplayProps {
|
interface SessionSummaryDisplayProps {
|
||||||
duration: string;
|
duration: string;
|
||||||
@@ -14,5 +15,8 @@ interface SessionSummaryDisplayProps {
|
|||||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||||
duration,
|
duration,
|
||||||
}) => (
|
}) => (
|
||||||
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
|
<StatsDisplay
|
||||||
|
title={t('Agent powering down. Goodbye!')}
|
||||||
|
duration={duration}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { LoadedSettings, Settings } from '../../config/settings.js';
|
|||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
getDialogSettingKeys,
|
getDialogSettingKeys,
|
||||||
setPendingSettingValue,
|
setPendingSettingValue,
|
||||||
@@ -124,7 +125,9 @@ export function SettingsDialog({
|
|||||||
const definition = getSettingDefinition(key);
|
const definition = getSettingDefinition(key);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: definition?.label || key,
|
label: definition?.label
|
||||||
|
? t(definition.label) || definition.label
|
||||||
|
: key,
|
||||||
value: key,
|
value: key,
|
||||||
type: definition?.type,
|
type: definition?.type,
|
||||||
toggle: () => {
|
toggle: () => {
|
||||||
@@ -779,7 +782,8 @@ export function SettingsDialog({
|
|||||||
>
|
>
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
<Text bold={focusSection === 'settings'} wrap="truncate">
|
<Text bold={focusSection === 'settings'} wrap="truncate">
|
||||||
{focusSection === 'settings' ? '> ' : ' '}Settings
|
{focusSection === 'settings' ? '> ' : ' '}
|
||||||
|
{t('Settings')}
|
||||||
</Text>
|
</Text>
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
{showScrollUp && <Text color={theme.text.secondary}>▲</Text>}
|
{showScrollUp && <Text color={theme.text.secondary}>▲</Text>}
|
||||||
@@ -916,13 +920,15 @@ export function SettingsDialog({
|
|||||||
|
|
||||||
<Box height={1} />
|
<Box height={1} />
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
(Use Enter to select
|
{t('(Use Enter to select{{tabText}})', {
|
||||||
{showScopeSelection ? ', Tab to change focus' : ''})
|
tabText: showScopeSelection ? t(', Tab to change focus') : '',
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
{showRestartPrompt && (
|
{showRestartPrompt && (
|
||||||
<Text color={theme.status.warning}>
|
<Text color={theme.status.warning}>
|
||||||
To see changes, Qwen Code must be restarted. Press r to exit and
|
{t(
|
||||||
apply changes now.
|
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
|
|||||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
|
import { t } from '../../i18n/index.js';
|
||||||
|
|
||||||
export interface ShellConfirmationRequest {
|
export interface ShellConfirmationRequest {
|
||||||
commands: string[];
|
commands: string[];
|
||||||
@@ -51,17 +52,17 @@ export const ShellConfirmationDialog: React.FC<
|
|||||||
|
|
||||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
|
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
|
||||||
{
|
{
|
||||||
label: 'Yes, allow once',
|
label: t('Yes, allow once'),
|
||||||
value: ToolConfirmationOutcome.ProceedOnce,
|
value: ToolConfirmationOutcome.ProceedOnce,
|
||||||
key: 'Yes, allow once',
|
key: 'Yes, allow once',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Yes, allow always for this session',
|
label: t('Yes, allow always for this session'),
|
||||||
value: ToolConfirmationOutcome.ProceedAlways,
|
value: ToolConfirmationOutcome.ProceedAlways,
|
||||||
key: 'Yes, allow always for this session',
|
key: 'Yes, allow always for this session',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'No (esc)',
|
label: t('No (esc)'),
|
||||||
value: ToolConfirmationOutcome.Cancel,
|
value: ToolConfirmationOutcome.Cancel,
|
||||||
key: 'No (esc)',
|
key: 'No (esc)',
|
||||||
},
|
},
|
||||||
@@ -78,10 +79,10 @@ export const ShellConfirmationDialog: React.FC<
|
|||||||
>
|
>
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text bold color={theme.text.primary}>
|
<Text bold color={theme.text.primary}>
|
||||||
Shell Command Execution
|
{t('Shell Command Execution')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={theme.text.primary}>
|
<Text color={theme.text.primary}>
|
||||||
A custom command wants to run the following shell commands:
|
{t('A custom command wants to run the following shell commands:')}
|
||||||
</Text>
|
</Text>
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -99,7 +100,7 @@ export const ShellConfirmationDialog: React.FC<
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text color={theme.text.primary}>Do you want to proceed?</Text>
|
<Text color={theme.text.primary}>{t('Do you want to proceed?')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
|
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user