mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 01:23:53 +00:00
Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/write-and-read-file-in-vscode
This commit is contained in:
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -224,5 +224,4 @@ jobs:
|
||||
run: |-
|
||||
gh issue create \
|
||||
--title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
|
||||
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}" \
|
||||
--label "kind/bug,release-failure"
|
||||
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
|
||||
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
@@ -73,7 +73,16 @@
|
||||
"request": "launch",
|
||||
"name": "Launch CLI Non-Interactive",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"],
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"start",
|
||||
"--",
|
||||
"-p",
|
||||
"${input:prompt}",
|
||||
"-y",
|
||||
"--output-format",
|
||||
"stream-json"
|
||||
],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
|
||||
|
||||
## 💡 Free Options Available
|
||||
|
||||
|
||||
@@ -195,6 +195,16 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **`/init`**
|
||||
- **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions.
|
||||
|
||||
- [**`/language`**](./language.md)
|
||||
- **Description:** View or change the language setting for both UI and LLM output.
|
||||
- **Sub-commands:**
|
||||
- **`ui`**: Set the UI language (zh-CN or en-US)
|
||||
- **`output`**: Set the LLM output language
|
||||
- **Usage:** `/language [ui|output] [language]`
|
||||
- **Examples:**
|
||||
- `/language ui zh-CN` (set UI language to Simplified Chinese)
|
||||
- `/language output English` (set LLM output language to English)
|
||||
|
||||
### Custom Commands
|
||||
|
||||
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.
|
||||
|
||||
@@ -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.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `qwen -i "explain this code"`
|
||||
- **`--output-format <format>`**:
|
||||
- **`--output-format <format>`** (**`-o <format>`**):
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output.
|
||||
- **Note:** For structured output and scripting, use the `--output-format json` flag.
|
||||
- `json`: A machine-readable JSON output emitted at the end of execution.
|
||||
- `stream-json`: Streaming JSON messages emitted as they occur during execution.
|
||||
- **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless.md) for detailed information.
|
||||
- **`--input-format <format>`**:
|
||||
- **Description:** Specifies the format consumed from standard input.
|
||||
- **Values:**
|
||||
- `text`: (Default) Standard text input from stdin or command-line arguments.
|
||||
- `stream-json`: JSON message protocol via stdin for bidirectional communication.
|
||||
- **Requirement:** `--input-format stream-json` requires `--output-format stream-json` to be set.
|
||||
- **Note:** When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless.md) for detailed information.
|
||||
- **`--include-partial-messages`**:
|
||||
- **Description:** Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming.
|
||||
- **Default:** `false`
|
||||
- **Requirement:** Requires `--output-format stream-json` to be set.
|
||||
- **Note:** See [Headless Mode](../features/headless.md) for detailed information about stream events.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--sandbox-image`**:
|
||||
|
||||
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)
|
||||
- [Text Output (Default)](#text-output-default)
|
||||
- [JSON Output](#json-output)
|
||||
- [Response Schema](#response-schema)
|
||||
- [Example Usage](#example-usage)
|
||||
- [Stream-JSON Output](#stream-json-output)
|
||||
- [Input Format](#input-format)
|
||||
- [File Redirection](#file-redirection)
|
||||
- [Configuration Options](#configuration-options)
|
||||
- [Examples](#examples)
|
||||
@@ -22,7 +23,7 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
||||
- [Generate commit messages](#generate-commit-messages)
|
||||
- [API documentation](#api-documentation)
|
||||
- [Batch code analysis](#batch-code-analysis)
|
||||
- [Code review](#code-review-1)
|
||||
- [PR code review](#pr-code-review)
|
||||
- [Log analysis](#log-analysis)
|
||||
- [Release notes generation](#release-notes-generation)
|
||||
- [Model and tool usage tracking](#model-and-tool-usage-tracking)
|
||||
@@ -66,6 +67,8 @@ cat README.md | qwen --prompt "Summarize this documentation"
|
||||
|
||||
## Output Formats
|
||||
|
||||
Qwen Code supports multiple output formats for different use cases:
|
||||
|
||||
### Text Output (Default)
|
||||
|
||||
Standard human-readable output:
|
||||
@@ -82,56 +85,9 @@ The capital of France is Paris.
|
||||
|
||||
### JSON Output
|
||||
|
||||
Returns structured data including response, statistics, and metadata. This
|
||||
format is ideal for programmatic processing and automation scripts.
|
||||
Returns structured data as a JSON array. All messages are buffered and output together when the session completes. This format is ideal for programmatic processing and automation scripts.
|
||||
|
||||
#### Response Schema
|
||||
|
||||
The JSON output follows this high-level structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"response": "string", // The main AI-generated content answering your prompt
|
||||
"stats": {
|
||||
// Usage metrics and performance data
|
||||
"models": {
|
||||
// Per-model API and token usage statistics
|
||||
"[model-name]": {
|
||||
"api": {
|
||||
/* request counts, errors, latency */
|
||||
},
|
||||
"tokens": {
|
||||
/* prompt, response, cached, total counts */
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
// Tool execution statistics
|
||||
"totalCalls": "number",
|
||||
"totalSuccess": "number",
|
||||
"totalFail": "number",
|
||||
"totalDurationMs": "number",
|
||||
"totalDecisions": {
|
||||
/* accept, reject, modify, auto_accept counts */
|
||||
},
|
||||
"byName": {
|
||||
/* per-tool detailed stats */
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
// File modification statistics
|
||||
"totalLinesAdded": "number",
|
||||
"totalLinesRemoved": "number"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
// Present only when an error occurred
|
||||
"type": "string", // Error type (e.g., "ApiError", "AuthError")
|
||||
"message": "string", // Human-readable error description
|
||||
"code": "number" // Optional error code
|
||||
}
|
||||
}
|
||||
```
|
||||
The JSON output is an array of message objects. The output includes multiple message types: system messages (session initialization), assistant messages (AI responses), and result messages (execution summary).
|
||||
|
||||
#### Example Usage
|
||||
|
||||
@@ -139,63 +95,81 @@ The JSON output follows this high-level structure:
|
||||
qwen -p "What is the capital of France?" --output-format json
|
||||
```
|
||||
|
||||
Response:
|
||||
Output (at end of execution):
|
||||
|
||||
```json
|
||||
{
|
||||
"response": "The capital of France is Paris.",
|
||||
"stats": {
|
||||
"models": {
|
||||
"qwen3-coder-plus": {
|
||||
"api": {
|
||||
"totalRequests": 2,
|
||||
"totalErrors": 0,
|
||||
"totalLatencyMs": 5053
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 24939,
|
||||
"candidates": 20,
|
||||
"total": 25113,
|
||||
"cached": 21263,
|
||||
"thoughts": 154,
|
||||
"tool": 0
|
||||
[
|
||||
{
|
||||
"type": "system",
|
||||
"subtype": "session_start",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"model": "qwen3-coder-plus",
|
||||
...
|
||||
},
|
||||
{
|
||||
"type": "assistant",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"message": {
|
||||
"id": "...",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "qwen3-coder-plus",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The capital of France is Paris."
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {...}
|
||||
},
|
||||
"tools": {
|
||||
"totalCalls": 1,
|
||||
"totalSuccess": 1,
|
||||
"totalFail": 0,
|
||||
"totalDurationMs": 1881,
|
||||
"totalDecisions": {
|
||||
"accept": 0,
|
||||
"reject": 0,
|
||||
"modify": 0,
|
||||
"auto_accept": 1
|
||||
},
|
||||
"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
|
||||
}
|
||||
"parent_tool_use_id": null
|
||||
},
|
||||
{
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"is_error": false,
|
||||
"duration_ms": 1234,
|
||||
"result": "The capital of France is Paris.",
|
||||
"usage": {...}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Stream-JSON Output
|
||||
|
||||
Stream-JSON format emits JSON messages immediately as they occur during execution, enabling real-time monitoring. This format uses line-delimited JSON where each message is a complete JSON object on a single line.
|
||||
|
||||
```bash
|
||||
qwen -p "Explain TypeScript" --output-format stream-json
|
||||
```
|
||||
|
||||
Output (streaming as events occur):
|
||||
|
||||
```json
|
||||
{"type":"system","subtype":"session_start","uuid":"...","session_id":"..."}
|
||||
{"type":"assistant","uuid":"...","session_id":"...","message":{...}}
|
||||
{"type":"result","subtype":"success","uuid":"...","session_id":"..."}
|
||||
```
|
||||
|
||||
When combined with `--include-partial-messages`, additional stream events are emitted in real-time (message_start, content_block_delta, etc.) for real-time UI updates.
|
||||
|
||||
```bash
|
||||
qwen -p "Write a Python script" --output-format stream-json --include-partial-messages
|
||||
```
|
||||
|
||||
### Input Format
|
||||
|
||||
The `--input-format` parameter controls how Qwen Code consumes input from standard input:
|
||||
|
||||
- **`text`** (default): Standard text input from stdin or command-line arguments
|
||||
- **`stream-json`**: JSON message protocol via stdin for bidirectional communication
|
||||
|
||||
> **Note:** Stream-json input mode is currently under construction and is intended for SDK integration. It requires `--output-format stream-json` to be set.
|
||||
|
||||
### File Redirection
|
||||
|
||||
Save output to files or pipe to other commands:
|
||||
@@ -212,48 +186,53 @@ qwen -p "Add more details" >> docker-explanation.txt
|
||||
qwen -p "What is Kubernetes?" --output-format json | jq '.response'
|
||||
qwen -p "Explain microservices" | wc -w
|
||||
qwen -p "List programming languages" | grep -i "python"
|
||||
|
||||
# Stream-JSON output for real-time processing
|
||||
qwen -p "Explain Docker" --output-format stream-json | jq '.type'
|
||||
qwen -p "Write code" --output-format stream-json --include-partial-messages | jq '.event.type'
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
Key command-line options for headless usage:
|
||||
|
||||
| Option | Description | Example |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format` | Specify output format (text, json) | `qwen -p "query" --output-format json` |
|
||||
| `--model`, `-m` | Specify the Qwen model | `qwen -p "query" -m qwen3-coder-plus` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--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).
|
||||
|
||||
## Examples
|
||||
|
||||
#### Code review
|
||||
### Code review
|
||||
|
||||
```bash
|
||||
cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt
|
||||
```
|
||||
|
||||
#### Generate commit messages
|
||||
### Generate commit messages
|
||||
|
||||
```bash
|
||||
result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json)
|
||||
echo "$result" | jq -r '.response'
|
||||
```
|
||||
|
||||
#### API documentation
|
||||
### API documentation
|
||||
|
||||
```bash
|
||||
result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json)
|
||||
echo "$result" | jq -r '.response' > openapi.json
|
||||
```
|
||||
|
||||
#### Batch code analysis
|
||||
### Batch code analysis
|
||||
|
||||
```bash
|
||||
for file in src/*.py; do
|
||||
@@ -264,20 +243,20 @@ for file in src/*.py; do
|
||||
done
|
||||
```
|
||||
|
||||
#### Code review
|
||||
### PR code review
|
||||
|
||||
```bash
|
||||
result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json)
|
||||
echo "$result" | jq -r '.response' > pr-review.json
|
||||
```
|
||||
|
||||
#### Log analysis
|
||||
### Log analysis
|
||||
|
||||
```bash
|
||||
grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt
|
||||
```
|
||||
|
||||
#### Release notes generation
|
||||
### Release notes generation
|
||||
|
||||
```bash
|
||||
result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json)
|
||||
@@ -286,7 +265,7 @@ echo "$response"
|
||||
echo "$response" >> CHANGELOG.md
|
||||
```
|
||||
|
||||
#### Model and tool usage tracking
|
||||
### Model and tool usage tracking
|
||||
|
||||
```bash
|
||||
result=$(qwen -p "Explain this database schema" --include-directories db --output-format json)
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('JSON output', () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it('should return a valid JSON with response and stats', async () => {
|
||||
it('should return a valid JSON array with result message containing response and stats', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
@@ -27,15 +27,34 @@ describe('JSON output', () => {
|
||||
);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed).toHaveProperty('response');
|
||||
expect(typeof parsed.response).toBe('string');
|
||||
expect(parsed.response.toLowerCase()).toContain('paris');
|
||||
// The output should be an array of messages
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
|
||||
expect(parsed).toHaveProperty('stats');
|
||||
expect(typeof parsed.stats).toBe('object');
|
||||
// Find the result message (should be the last message)
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(typeof resultMessage.result).toBe('string');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
|
||||
// Stats may be present if available
|
||||
if ('stats' in resultMessage) {
|
||||
expect(typeof resultMessage.stats).toBe('object');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return a JSON error for enforced auth mismatch before running', async () => {
|
||||
const originalOpenaiApiKey = process.env['OPENAI_API_KEY'];
|
||||
process.env['OPENAI_API_KEY'] = 'test-key';
|
||||
await rig.setup('json-output-auth-mismatch', {
|
||||
settings: {
|
||||
@@ -50,38 +69,242 @@ describe('JSON output', () => {
|
||||
} catch (e) {
|
||||
thrown = e as Error;
|
||||
} finally {
|
||||
delete process.env['OPENAI_API_KEY'];
|
||||
process.env['OPENAI_API_KEY'] = originalOpenaiApiKey;
|
||||
}
|
||||
|
||||
expect(thrown).toBeDefined();
|
||||
const message = (thrown as Error).message;
|
||||
|
||||
// Use a regex to find the first complete JSON object in the string
|
||||
const jsonMatch = message.match(/{[\s\S]*}/);
|
||||
|
||||
// Fail if no JSON-like text was found
|
||||
// The error JSON is written to stdout as a CLIResultMessageError
|
||||
// Extract stdout from the error message
|
||||
const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/);
|
||||
expect(
|
||||
jsonMatch,
|
||||
'Expected to find a JSON object in the error output',
|
||||
stdoutMatch,
|
||||
'Expected to find stdout in the error message',
|
||||
).toBeTruthy();
|
||||
|
||||
let payload;
|
||||
const stdout = stdoutMatch![1];
|
||||
let parsed: unknown[];
|
||||
try {
|
||||
// Parse the matched JSON string
|
||||
payload = JSON.parse(jsonMatch![0]);
|
||||
// Parse the JSON array from stdout
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse the following JSON:', jsonMatch![0]);
|
||||
console.error('Failed to parse the following JSON:', stdout);
|
||||
throw new Error(
|
||||
`Test failed: Could not parse JSON from error message. Details: ${parseError}`,
|
||||
`Test failed: Could not parse JSON from stdout. Details: ${parseError}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(payload.error).toBeDefined();
|
||||
expect(payload.error.type).toBe('Error');
|
||||
expect(payload.error.code).toBe(1);
|
||||
expect(payload.error.message).toContain(
|
||||
// The output should be an array of messages
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the result message with error
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result' &&
|
||||
'is_error' in msg &&
|
||||
msg.is_error === true,
|
||||
) as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
subtype: string;
|
||||
error?: { message: string; type?: string };
|
||||
};
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage.is_error).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('subtype');
|
||||
expect(resultMessage.subtype).toBe('error_during_execution');
|
||||
expect(resultMessage).toHaveProperty('error');
|
||||
expect(resultMessage.error).toBeDefined();
|
||||
expect(resultMessage.error?.message).toContain(
|
||||
'configured auth type is qwen-oauth',
|
||||
);
|
||||
expect(payload.error.message).toContain('current auth type is openai');
|
||||
expect(resultMessage.error?.message).toContain(
|
||||
'current auth type is openai',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return line-delimited JSON messages for stream-json output format', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
);
|
||||
|
||||
// Stream-json output is line-delimited JSON (one JSON object per line)
|
||||
const lines = result
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
|
||||
// Parse each line as a JSON object
|
||||
const messages: unknown[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
messages.push(parsed);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have at least system, assistant, and result messages
|
||||
expect(messages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Find system message
|
||||
const systemMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'system',
|
||||
);
|
||||
expect(systemMessage).toBeDefined();
|
||||
expect(systemMessage).toHaveProperty('subtype');
|
||||
expect(systemMessage).toHaveProperty('session_id');
|
||||
|
||||
// Find assistant message
|
||||
const assistantMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'assistant',
|
||||
);
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage).toHaveProperty('message');
|
||||
expect(assistantMessage).toHaveProperty('session_id');
|
||||
|
||||
// Find result message (should be the last message)
|
||||
const resultMessage = messages[messages.length - 1] as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
result: string;
|
||||
};
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(
|
||||
typeof resultMessage === 'object' &&
|
||||
resultMessage !== null &&
|
||||
'type' in resultMessage &&
|
||||
resultMessage.type === 'result',
|
||||
).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(typeof resultMessage.result).toBe('string');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
});
|
||||
|
||||
it('should include stream events when using stream-json with include-partial-messages', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--include-partial-messages',
|
||||
);
|
||||
|
||||
// Stream-json output is line-delimited JSON (one JSON object per line)
|
||||
const lines = result
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
|
||||
// Parse each line as a JSON object
|
||||
const messages: unknown[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
messages.push(parsed);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have more messages than without include-partial-messages
|
||||
// because we're including stream events
|
||||
expect(messages.length).toBeGreaterThan(3);
|
||||
|
||||
// Find stream_event messages
|
||||
const streamEvents = messages.filter(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'stream_event',
|
||||
);
|
||||
expect(streamEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify stream event structure
|
||||
const firstStreamEvent = streamEvents[0];
|
||||
expect(firstStreamEvent).toHaveProperty('event');
|
||||
expect(firstStreamEvent).toHaveProperty('session_id');
|
||||
expect(firstStreamEvent).toHaveProperty('uuid');
|
||||
|
||||
// Check for expected stream event types
|
||||
const eventTypes = streamEvents.map((event: unknown) =>
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
'event' in event &&
|
||||
typeof event.event === 'object' &&
|
||||
event.event !== null &&
|
||||
'type' in event.event
|
||||
? event.event.type
|
||||
: null,
|
||||
);
|
||||
|
||||
// Should have message_start event
|
||||
expect(eventTypes).toContain('message_start');
|
||||
|
||||
// Should have content_block_start event
|
||||
expect(eventTypes).toContain('content_block_start');
|
||||
|
||||
// Should have content_block_delta events
|
||||
expect(eventTypes).toContain('content_block_delta');
|
||||
|
||||
// Should have content_block_stop event
|
||||
expect(eventTypes).toContain('content_block_stop');
|
||||
|
||||
// Should have message_stop event
|
||||
expect(eventTypes).toContain('message_stop');
|
||||
|
||||
// Verify that we still have the complete assistant message
|
||||
const assistantMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'assistant',
|
||||
);
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage).toHaveProperty('message');
|
||||
|
||||
// Verify that we still have the result message
|
||||
const resultMessage = messages[messages.length - 1] as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
result: string;
|
||||
};
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(
|
||||
typeof resultMessage === 'object' &&
|
||||
resultMessage !== null &&
|
||||
'type' in resultMessage &&
|
||||
resultMessage.type === 'result',
|
||||
).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,7 +340,8 @@ export class TestRig {
|
||||
// as it would corrupt the JSON
|
||||
const isJsonOutput =
|
||||
commandArgs.includes('--output-format') &&
|
||||
commandArgs.includes('json');
|
||||
(commandArgs.includes('json') ||
|
||||
commandArgs.includes('stream-json'));
|
||||
|
||||
// If we have stderr output and it's not a JSON test, include that also
|
||||
if (stderr && !isJsonOutput) {
|
||||
@@ -349,7 +350,23 @@ export class TestRig {
|
||||
|
||||
resolve(result);
|
||||
} 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",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17088,7 +17088,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -17203,7 +17203,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -17342,7 +17342,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -17354,7 +17354,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
@@ -46,6 +46,7 @@
|
||||
"lint:all": "node scripts/lint.js",
|
||||
"format": "prettier --experimental-cli --write .",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"check-i18n": "npm run check-i18n --workspace=packages/cli",
|
||||
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
|
||||
"prepare": "husky && npm run bundle",
|
||||
"prepare:package": "node scripts/prepare-package.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -8,9 +8,16 @@
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"qwen": "dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build_package.js",
|
||||
"start": "node dist/index.js",
|
||||
@@ -19,13 +26,14 @@
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:ci": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check-i18n": "tsx ../../scripts/check-i18n.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
|
||||
@@ -392,6 +392,49 @@ describe('parseArguments', () => {
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw an error when include-partial-messages is used without stream-json output', async () => {
|
||||
process.argv = ['node', 'script.js', '--include-partial-messages'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'--include-partial-messages requires --output-format stream-json',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('should parse stream-json formats and include-partial-messages flag', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--include-partial-messages',
|
||||
];
|
||||
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
expect(argv.outputFormat).toBe('stream-json');
|
||||
expect(argv.inputFormat).toBe('stream-json');
|
||||
expect(argv.includePartialMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow --approval-mode without --yolo', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
@@ -473,6 +516,34 @@ describe('loadCliConfig', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should propagate stream-json formats to config', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--include-partial-messages',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getOutputFormat()).toBe('stream-json');
|
||||
expect(config.getInputFormat()).toBe('stream-json');
|
||||
expect(config.getIncludePartialMessages()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
|
||||
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type {
|
||||
FileFilteringOptions,
|
||||
MCPServerConfig,
|
||||
OutputFormat,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import {
|
||||
@@ -24,6 +23,9 @@ import {
|
||||
WriteFileTool,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
Storage,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
@@ -124,7 +126,24 @@ export interface CliArgs {
|
||||
screenReader: boolean | undefined;
|
||||
vlmSwitchMode: string | undefined;
|
||||
useSmartEdit: boolean | undefined;
|
||||
inputFormat?: string | undefined;
|
||||
outputFormat: string | undefined;
|
||||
includePartialMessages?: boolean;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
format: string | OutputFormat | undefined,
|
||||
): OutputFormat | undefined {
|
||||
if (!format) {
|
||||
return undefined;
|
||||
}
|
||||
if (format === OutputFormat.STREAM_JSON) {
|
||||
return OutputFormat.STREAM_JSON;
|
||||
}
|
||||
if (format === 'json' || format === OutputFormat.JSON) {
|
||||
return OutputFormat.JSON;
|
||||
}
|
||||
return OutputFormat.TEXT;
|
||||
}
|
||||
|
||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
@@ -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: process.env['VLM_SWITCH_MODE'],
|
||||
})
|
||||
.option('input-format', {
|
||||
type: 'string',
|
||||
choices: ['text', 'stream-json'],
|
||||
description: 'The format consumed from standard input.',
|
||||
default: 'text',
|
||||
})
|
||||
.option('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
description: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
choices: ['text', 'json', 'stream-json'],
|
||||
})
|
||||
.option('include-partial-messages', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Include partial assistant messages when using stream-json output.',
|
||||
default: false,
|
||||
})
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
@@ -408,6 +439,18 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
if (argv['yolo'] && argv['approvalMode']) {
|
||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
||||
}
|
||||
if (
|
||||
argv['includePartialMessages'] &&
|
||||
argv['outputFormat'] !== OutputFormat.STREAM_JSON
|
||||
) {
|
||||
return '--include-partial-messages requires --output-format stream-json';
|
||||
}
|
||||
if (
|
||||
argv['inputFormat'] === 'stream-json' &&
|
||||
argv['outputFormat'] !== OutputFormat.STREAM_JSON
|
||||
) {
|
||||
return '--input-format stream-json requires --output-format stream-json';
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
)
|
||||
@@ -560,6 +603,20 @@ export async function loadCliConfig(
|
||||
(e) => e.contextFiles,
|
||||
);
|
||||
|
||||
// Automatically load output-language.md if it exists
|
||||
const outputLanguageFilePath = path.join(
|
||||
Storage.getGlobalQwenDir(),
|
||||
'output-language.md',
|
||||
);
|
||||
if (fs.existsSync(outputLanguageFilePath)) {
|
||||
extensionContextFilePaths.push(outputLanguageFilePath);
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`Found output-language.md, adding to context files: ${outputLanguageFilePath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fileService = new FileDiscoveryService(cwd);
|
||||
|
||||
const fileFiltering = {
|
||||
@@ -588,6 +645,22 @@ export async function loadCliConfig(
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
const inputFormat: InputFormat =
|
||||
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
|
||||
const argvOutputFormat = normalizeOutputFormat(
|
||||
argv.outputFormat as string | OutputFormat | undefined,
|
||||
);
|
||||
const settingsOutputFormat = normalizeOutputFormat(settings.output?.format);
|
||||
const outputFormat =
|
||||
argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT;
|
||||
const outputSettingsFormat: OutputFormat =
|
||||
outputFormat === OutputFormat.STREAM_JSON
|
||||
? settingsOutputFormat &&
|
||||
settingsOutputFormat !== OutputFormat.STREAM_JSON
|
||||
? settingsOutputFormat
|
||||
: OutputFormat.TEXT
|
||||
: (outputFormat as OutputFormat);
|
||||
const includePartialMessages = Boolean(argv.includePartialMessages);
|
||||
|
||||
// Determine approval mode with backward compatibility
|
||||
let approvalMode: ApprovalMode;
|
||||
@@ -629,11 +702,31 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
||||
// Interactive mode determination with priority:
|
||||
// 1. If promptInteractive (-i flag) is provided, it is explicitly interactive
|
||||
// 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive
|
||||
// 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive
|
||||
const hasQuery = !!argv.query;
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
|
||||
const hasPrompt = !!argv.prompt;
|
||||
let interactive: boolean;
|
||||
if (argv.promptInteractive) {
|
||||
// Priority 1: Explicit -i flag means interactive
|
||||
interactive = true;
|
||||
} else if (
|
||||
(outputFormat === OutputFormat.STREAM_JSON ||
|
||||
outputFormat === OutputFormat.JSON) &&
|
||||
(hasQuery || hasPrompt)
|
||||
) {
|
||||
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
|
||||
interactive = false;
|
||||
} else if (!hasQuery && !hasPrompt) {
|
||||
// Priority 3: No query or prompt means interactive only if TTY (format arguments ignored)
|
||||
interactive = process.stdin.isTTY ?? false;
|
||||
} else {
|
||||
// Default: If we have query/prompt but output format is TEXT, assume non-interactive
|
||||
// (fallback for edge cases where query/prompt is provided with TEXT output)
|
||||
interactive = false;
|
||||
}
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive && !argv.experimentalAcp) {
|
||||
@@ -755,6 +848,9 @@ export async function loadCliConfig(
|
||||
blockedMcpServers,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
authType: settings.security?.auth?.selectedType,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
generationConfig: {
|
||||
...(settings.model?.generationConfig || {}),
|
||||
model: resolvedModel,
|
||||
@@ -789,7 +885,6 @@ export async function loadCliConfig(
|
||||
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
|
||||
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
vlmSwitchMode,
|
||||
@@ -799,7 +894,7 @@ export async function loadCliConfig(
|
||||
eventEmitter: appEvents,
|
||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||
output: {
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
format: outputSettingsFormat,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
disableAutoUpdate: 'general.disableAutoUpdate',
|
||||
disableUpdateNag: 'general.disableUpdateNag',
|
||||
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
|
||||
enablePromptCompletion: 'general.enablePromptCompletion',
|
||||
enforcedAuthType: 'security.auth.enforcedType',
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
@@ -484,6 +483,27 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a minimal LoadedSettings instance with empty settings.
|
||||
* Used in stream-json mode where settings are ignored.
|
||||
*/
|
||||
export function createMinimalSettings(): LoadedSettings {
|
||||
const emptySettingsFile: SettingsFile = {
|
||||
path: '',
|
||||
settings: {},
|
||||
originalSettings: {},
|
||||
rawJson: '{}',
|
||||
};
|
||||
return new LoadedSettings(
|
||||
emptySettingsFile,
|
||||
emptySettingsFile,
|
||||
emptySettingsFile,
|
||||
emptySettingsFile,
|
||||
false,
|
||||
new Set(),
|
||||
);
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
|
||||
@@ -167,16 +167,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
enablePromptCompletion: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Prompt Completion',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable AI-powered prompt completion suggestions while typing.',
|
||||
showInDialog: true,
|
||||
},
|
||||
debugKeystrokeLogging: {
|
||||
type: 'boolean',
|
||||
label: 'Debug Keystroke Logging',
|
||||
@@ -186,6 +176,23 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable debug logging of keystrokes to the console.',
|
||||
showInDialog: true,
|
||||
},
|
||||
language: {
|
||||
type: 'enum',
|
||||
label: 'Language',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 'auto',
|
||||
description:
|
||||
'The language for the user interface. Use "auto" to detect from system settings. ' +
|
||||
'You can also use custom language codes (e.g., "es", "fr") by placing JS language files ' +
|
||||
'in ~/.qwen/locales/ (e.g., ~/.qwen/locales/es.js).',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto (detect from system)' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文 (Chinese)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { type LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
import { initializeI18n } from '../i18n/index.js';
|
||||
|
||||
export interface InitializationResult {
|
||||
authError: string | null;
|
||||
@@ -33,6 +34,13 @@ export async function initializeApp(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): 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 authError = await performInitialAuth(config, authType);
|
||||
|
||||
@@ -44,7 +52,6 @@ export async function initializeApp(
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
const shouldOpenAuthDialog =
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { themeManager } from '../ui/themes/theme-manager.js';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Validates the configured theme.
|
||||
@@ -15,7 +16,9 @@ import { type LoadedSettings } from '../config/settings.js';
|
||||
export function validateTheme(settings: LoadedSettings): string | null {
|
||||
const effectiveTheme = settings.merged.ui?.theme;
|
||||
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
|
||||
return `Theme "${effectiveTheme}" not found.`;
|
||||
return t('Theme "{{themeName}}" not found.', {
|
||||
themeName: effectiveTheme,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { type LoadedSettings } from './config/settings.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { OutputFormat } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Custom error to identify mock process.exit calls
|
||||
class MockProcessExitError extends Error {
|
||||
@@ -158,6 +159,7 @@ describe('gemini.tsx main function', () => {
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getOutputFormat: () => OutputFormat.TEXT,
|
||||
} as unknown as Config;
|
||||
});
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
@@ -230,6 +232,143 @@ describe('gemini.tsx main function', () => {
|
||||
// Avoid the process.exit error from being thrown.
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('invokes runNonInteractiveStreamJson and performs cleanup in stream-json mode', async () => {
|
||||
const originalIsTTY = Object.getOwnPropertyDescriptor(
|
||||
process.stdin,
|
||||
'isTTY',
|
||||
);
|
||||
const originalIsRaw = Object.getOwnPropertyDescriptor(
|
||||
process.stdin,
|
||||
'isRaw',
|
||||
);
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'isRaw', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const cleanupModule = await import('./utils/cleanup.js');
|
||||
const extensionModule = await import('./config/extension.js');
|
||||
const validatorModule = await import('./validateNonInterActiveAuth.js');
|
||||
const streamJsonModule = await import('./nonInteractive/session.js');
|
||||
const initializerModule = await import('./core/initializer.js');
|
||||
const startupWarningsModule = await import('./utils/startupWarnings.js');
|
||||
const userStartupWarningsModule = await import(
|
||||
'./utils/userStartupWarnings.js'
|
||||
);
|
||||
|
||||
vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);
|
||||
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
|
||||
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
|
||||
runExitCleanupMock.mockResolvedValue(undefined);
|
||||
vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]);
|
||||
vi.spyOn(
|
||||
extensionModule.ExtensionStorage,
|
||||
'getUserExtensionsDir',
|
||||
).mockReturnValue('/tmp/extensions');
|
||||
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
});
|
||||
vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);
|
||||
vi.spyOn(
|
||||
userStartupWarningsModule,
|
||||
'getUserStartupWarnings',
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const validatedConfig = { validated: true } as unknown as Config;
|
||||
const validateAuthSpy = vi
|
||||
.spyOn(validatorModule, 'validateNonInteractiveAuth')
|
||||
.mockResolvedValue(validatedConfig);
|
||||
const runStreamJsonSpy = vi
|
||||
.spyOn(streamJsonModule, 'runNonInteractiveStreamJson')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
errors: [],
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
} as never);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
extensions: [],
|
||||
} as never);
|
||||
|
||||
const configStub = {
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => ' hello stream ',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getMcpServers: () => ({}),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getInputFormat: () => 'stream-json',
|
||||
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
|
||||
|
||||
process.env['SANDBOX'] = '1';
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
if (!(error instanceof MockProcessExitError)) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
processExitSpy.mockRestore();
|
||||
if (originalIsTTY) {
|
||||
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
|
||||
} else {
|
||||
delete (process.stdin as { isTTY?: unknown }).isTTY;
|
||||
}
|
||||
if (originalIsRaw) {
|
||||
Object.defineProperty(process.stdin, 'isRaw', originalIsRaw);
|
||||
} else {
|
||||
delete (process.stdin as { isRaw?: unknown }).isRaw;
|
||||
}
|
||||
delete process.env['SANDBOX'];
|
||||
}
|
||||
|
||||
expect(runStreamJsonSpy).toHaveBeenCalledTimes(1);
|
||||
const [configArg, inputArg] = runStreamJsonSpy.mock.calls[0];
|
||||
expect(configArg).toBe(validatedConfig);
|
||||
expect(inputArg).toBe('hello stream');
|
||||
|
||||
expect(validateAuthSpy).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
configStub,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini.tsx main function kitty protocol', () => {
|
||||
@@ -337,7 +476,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
screenReader: undefined,
|
||||
vlmSwitchMode: undefined,
|
||||
useSmartEdit: undefined,
|
||||
inputFormat: undefined,
|
||||
outputFormat: undefined,
|
||||
includePartialMessages: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
@@ -412,6 +553,7 @@ describe('startInteractiveUI', () => {
|
||||
vi.mock('./utils/cleanup.js', () => ({
|
||||
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
|
||||
registerCleanup: vi.fn(),
|
||||
runExitCleanup: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock('ink', () => ({
|
||||
|
||||
@@ -4,58 +4,60 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
InputFormat,
|
||||
logUserPrompt,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { render } from 'ink';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import dns from 'node:dns';
|
||||
import os from 'node:os';
|
||||
import { basename } from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import React from 'react';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||
import { themeManager } from './ui/themes/theme-manager.js';
|
||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
} from './core/initializer.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import { runNonInteractiveStreamJson } from './nonInteractive/session.js';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { themeManager } from './ui/themes/theme-manager.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import {
|
||||
cleanupCheckpoints,
|
||||
registerCleanup,
|
||||
runExitCleanup,
|
||||
} from './utils/cleanup.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
logUserPrompt,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
} from './core/initializer.js';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { AppEvent, appEvents } from './utils/events.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import {
|
||||
relaunchOnExitCode,
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
} from './utils/relaunch.js';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
@@ -106,9 +108,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
@@ -218,12 +220,6 @@ export async function main() {
|
||||
}
|
||||
|
||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: isDebugMode,
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
dns.setDefaultResultOrder(
|
||||
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
|
||||
@@ -348,6 +344,15 @@ export async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Setup unified ConsolePatcher based on interactive mode
|
||||
const isInteractive = config.isInteractive();
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: isInteractive,
|
||||
debugMode: isDebugMode,
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
|
||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||
@@ -410,14 +415,43 @@ export async function main() {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
// If not a TTY, read from stdin
|
||||
// This is for cases where the user pipes input directly into the command
|
||||
if (!process.stdin.isTTY) {
|
||||
// Check input format BEFORE reading stdin
|
||||
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
|
||||
// Only read stdin if NOT in stream-json mode
|
||||
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
|
||||
// and should be consumed by StreamJsonInputReader instead
|
||||
if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) {
|
||||
const stdinData = await readStdin();
|
||||
if (stdinData) {
|
||||
input = `${stdinData}\n\n${input}`;
|
||||
}
|
||||
}
|
||||
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
|
||||
const prompt_id = Math.random().toString(16).slice(2);
|
||||
|
||||
if (inputFormat === InputFormat.STREAM_JSON) {
|
||||
const trimmedInput = (input ?? '').trim();
|
||||
|
||||
await runNonInteractiveStreamJson(
|
||||
nonInteractiveConfig,
|
||||
trimmedInput.length > 0 ? trimmedInput : '',
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
console.error(
|
||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
||||
@@ -425,7 +459,6 @@ export async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const prompt_id = Math.random().toString(16).slice(2);
|
||||
logUserPrompt(config, {
|
||||
'event.name': 'user_prompt',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
@@ -435,13 +468,6 @@ export async function main() {
|
||||
prompt_length: input.length,
|
||||
});
|
||||
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
|
||||
if (config.getDebugMode()) {
|
||||
console.log('Session ID: %s', sessionId);
|
||||
}
|
||||
|
||||
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,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
JsonFormatter,
|
||||
uiTelemetryService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||
import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
|
||||
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
||||
import type { ControlService } from './nonInteractive/control/ControlService.js';
|
||||
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
import {
|
||||
handleError,
|
||||
@@ -30,73 +32,144 @@ import {
|
||||
handleCancellationError,
|
||||
handleMaxTurnsExceededError,
|
||||
} from './utils/errors.js';
|
||||
import {
|
||||
normalizePartList,
|
||||
extractPartsFromUserMessage,
|
||||
buildSystemMessage,
|
||||
createTaskToolProgressHandler,
|
||||
computeUsageFromMetrics,
|
||||
} from './utils/nonInteractiveHelpers.js';
|
||||
|
||||
/**
|
||||
* Provides optional overrides for `runNonInteractive` execution.
|
||||
*
|
||||
* @param abortController - Optional abort controller for cancellation.
|
||||
* @param adapter - Optional JSON output adapter for structured output formats.
|
||||
* @param userMessage - Optional CLI user message payload for preformatted input.
|
||||
* @param controlService - Optional control service for future permission handling.
|
||||
*/
|
||||
export interface RunNonInteractiveOptions {
|
||||
abortController?: AbortController;
|
||||
adapter?: JsonOutputAdapterInterface;
|
||||
userMessage?: CLIUserMessage;
|
||||
controlService?: ControlService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the non-interactive CLI flow for a single request.
|
||||
*/
|
||||
export async function runNonInteractive(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
input: string,
|
||||
prompt_id: string,
|
||||
options: RunNonInteractiveOptions = {},
|
||||
): Promise<void> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
// Create output adapter based on format
|
||||
let adapter: JsonOutputAdapterInterface | undefined;
|
||||
const outputFormat = config.getOutputFormat();
|
||||
|
||||
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 {
|
||||
consolePatcher.patch();
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
process.stdout.on('error', stdoutErrorHandler);
|
||||
|
||||
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 (isSlashCommand(input)) {
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
input,
|
||||
abortController,
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
// If a slash command is found and returns a prompt, use it.
|
||||
// Otherwise, slashCommandResult fall through to the default prompt
|
||||
// handling.
|
||||
if (slashCommandResult) {
|
||||
query = slashCommandResult as Part[];
|
||||
}
|
||||
}
|
||||
|
||||
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 (!initialPartList) {
|
||||
let slashHandled = false;
|
||||
if (isSlashCommand(input)) {
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
input,
|
||||
abortController,
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
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) {
|
||||
turnCount++;
|
||||
if (
|
||||
@@ -105,43 +178,124 @@ export async function runNonInteractive(
|
||||
) {
|
||||
handleMaxTurnsExceededError(config);
|
||||
}
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
const apiStartTime = Date.now();
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
// Start assistant message for this turn
|
||||
if (adapter) {
|
||||
adapter.startAssistantMessage();
|
||||
}
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
handleCancellationError(config);
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += event.value;
|
||||
} else {
|
||||
process.stdout.write(event.value);
|
||||
if (adapter) {
|
||||
// Use adapter for all event processing
|
||||
adapter.processEvent(event);
|
||||
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
} else {
|
||||
// Text output mode - direct stdout
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
process.stdout.write(event.value);
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize assistant message
|
||||
if (adapter) {
|
||||
adapter.finalizeAssistantMessage();
|
||||
}
|
||||
totalApiDurationMs += Date.now() - apiStartTime;
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const finalRequestInfo = requestInfo;
|
||||
|
||||
/*
|
||||
if (options.controlService) {
|
||||
const permissionResult =
|
||||
await options.controlService.permission.shouldAllowTool(
|
||||
requestInfo,
|
||||
);
|
||||
if (!permissionResult.allowed) {
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
|
||||
permissionResult.message ?? '',
|
||||
);
|
||||
}
|
||||
if (adapter && permissionResult.message) {
|
||||
adapter.emitSystemMessage('tool_denied', {
|
||||
tool: requestInfo.name,
|
||||
message: permissionResult.message,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permissionResult.updatedArgs) {
|
||||
finalRequestInfo = {
|
||||
...requestInfo,
|
||||
args: permissionResult.updatedArgs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallUpdateCallback = options.controlService
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
*/
|
||||
|
||||
// Only pass outputUpdateHandler for Task tool
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
const taskToolProgress = isTaskTool
|
||||
? createTaskToolProgressHandler(
|
||||
config,
|
||||
finalRequestInfo.callId,
|
||||
adapter,
|
||||
)
|
||||
: undefined;
|
||||
const taskToolProgressHandler = taskToolProgress?.handler;
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
finalRequestInfo,
|
||||
abortController.signal,
|
||||
isTaskTool && taskToolProgressHandler
|
||||
? {
|
||||
outputUpdateHandler: taskToolProgressHandler,
|
||||
/*
|
||||
toolCallUpdateCallback
|
||||
? { onToolCallsUpdate: toolCallUpdateCallback }
|
||||
: undefined,
|
||||
*/
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
// adapter's messages array and will be output together on emitResult()
|
||||
|
||||
if (toolResponse.error) {
|
||||
// In JSON/STREAM_JSON mode, tool errors are tolerated and formatted
|
||||
// as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode
|
||||
// from config and allow the session to continue so the LLM can decide what to do next.
|
||||
// In text mode, we still log the error.
|
||||
handleToolError(
|
||||
requestInfo.name,
|
||||
finalRequestInfo.name,
|
||||
toolResponse.error,
|
||||
config,
|
||||
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
@@ -149,6 +303,13 @@ export async function runNonInteractive(
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
);
|
||||
// Note: We no longer emit a separate system message for tool errors
|
||||
// in JSON/STREAM_JSON mode, as the error is already captured in the
|
||||
// tool_result block with is_error=true.
|
||||
}
|
||||
|
||||
if (adapter) {
|
||||
adapter.emitToolResult(finalRequestInfo, toolResponse);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
@@ -157,20 +318,57 @@ export async function runNonInteractive(
|
||||
}
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
} else {
|
||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
process.stdout.write(formatter.format(responseText, stats));
|
||||
// For JSON and STREAM_JSON modes, compute usage from metrics
|
||||
if (adapter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const usage = computeUsageFromMetrics(metrics);
|
||||
// Get stats for JSON format output
|
||||
const stats =
|
||||
outputFormat === OutputFormat.JSON
|
||||
? uiTelemetryService.getMetrics()
|
||||
: undefined;
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: Date.now() - startTime,
|
||||
apiDurationMs: totalApiDurationMs,
|
||||
numTurns: turnCount,
|
||||
usage,
|
||||
stats,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write('\n'); // Ensure a final newline
|
||||
// Text output mode - no usage needed
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// For JSON and STREAM_JSON modes, compute usage from metrics
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (adapter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const usage = computeUsageFromMetrics(metrics);
|
||||
// Get stats for JSON format output
|
||||
const stats =
|
||||
outputFormat === OutputFormat.JSON
|
||||
? uiTelemetryService.getMetrics()
|
||||
: undefined;
|
||||
adapter.emitResult({
|
||||
isError: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
apiDurationMs: totalApiDurationMs,
|
||||
numTurns: turnCount,
|
||||
errorMessage: message,
|
||||
usage,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
handleError(error, config);
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
process.stdout.removeListener('error', stdoutErrorHandler);
|
||||
// Cleanup signal handlers
|
||||
process.removeListener('SIGINT', shutdownHandler);
|
||||
process.removeListener('SIGTERM', shutdownHandler);
|
||||
if (isTelemetrySdkInitialized()) {
|
||||
await shutdownTelemetry(config);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { languageCommand } from '../ui/commands/languageCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
@@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
helpCommand,
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
languageCommand,
|
||||
mcpCommand,
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
|
||||
@@ -89,6 +89,7 @@ import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
|
||||
import { useDialogClose } from './hooks/useDialogClose.js';
|
||||
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
||||
@@ -353,7 +354,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
handleAuthSelect,
|
||||
openAuthDialog,
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, config);
|
||||
} = useAuthCommand(settings, config, historyManager.addItem);
|
||||
|
||||
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
|
||||
config,
|
||||
@@ -384,7 +385,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
settings.merged.security?.auth.selectedType
|
||||
) {
|
||||
onAuthError(
|
||||
`Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
|
||||
t(
|
||||
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
|
||||
{
|
||||
enforcedType: settings.merged.security?.auth.enforcedType,
|
||||
currentType: settings.merged.security?.auth.selectedType,
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
settings.merged.security?.auth?.selectedType &&
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
function parseDefaultAuthType(
|
||||
defaultAuthType: string | undefined,
|
||||
@@ -39,10 +40,14 @@ export function AuthDialog(): React.JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
key: AuthType.QWEN_OAUTH,
|
||||
label: 'Qwen OAuth',
|
||||
label: t('Qwen OAuth'),
|
||||
value: AuthType.QWEN_OAUTH,
|
||||
},
|
||||
{ key: AuthType.USE_OPENAI, label: 'OpenAI', value: AuthType.USE_OPENAI },
|
||||
{
|
||||
key: AuthType.USE_OPENAI,
|
||||
label: t('OpenAI'),
|
||||
value: AuthType.USE_OPENAI,
|
||||
},
|
||||
];
|
||||
|
||||
const initialAuthIndex = Math.max(
|
||||
@@ -98,7 +103,9 @@ export function AuthDialog(): React.JSX.Element {
|
||||
if (settings.merged.security?.auth?.selectedType === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
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;
|
||||
}
|
||||
@@ -116,9 +123,9 @@ export function AuthDialog(): React.JSX.Element {
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Get started</Text>
|
||||
<Text bold>{t('Get started')}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>How would you like to authenticate for this project?</Text>
|
||||
<Text>{t('How would you like to authenticate for this project?')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
@@ -134,19 +141,19 @@ export function AuthDialog(): React.JSX.Element {
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
|
||||
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
|
||||
</Box>
|
||||
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
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.
|
||||
{t(
|
||||
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
|
||||
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentBlue}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface AuthInProgressProps {
|
||||
onTimeout: () => void;
|
||||
@@ -48,13 +49,13 @@ export function AuthInProgress({
|
||||
>
|
||||
{timedOut ? (
|
||||
<Text color={theme.status.error}>
|
||||
Authentication timed out. Please try again.
|
||||
{t('Authentication timed out. Please try again.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Box>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
|
||||
cancel)
|
||||
<Spinner type="dots" />{' '}
|
||||
{t('Waiting for auth... (Press ESC or CTRL+C to cancel)')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -4,23 +4,29 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthEvent,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getErrorMessage,
|
||||
logAuth,
|
||||
AuthEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { AuthState } from '../types.js';
|
||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
|
||||
import { useQwenAuth } from '../hooks/useQwenAuth.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
|
||||
|
||||
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
export const useAuthCommand = (
|
||||
settings: LoadedSettings,
|
||||
config: Config,
|
||||
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
||||
) => {
|
||||
const unAuthenticated =
|
||||
settings.merged.security?.auth?.selectedType === undefined;
|
||||
|
||||
@@ -55,7 +61,9 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
const handleAuthFailure = useCallback(
|
||||
(error: unknown) => {
|
||||
setIsAuthenticating(false);
|
||||
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
|
||||
const errorMessage = t('Failed to authenticate. Message: {{message}}', {
|
||||
message: getErrorMessage(error),
|
||||
});
|
||||
onAuthError(errorMessage);
|
||||
|
||||
// Log authentication failure
|
||||
@@ -117,8 +125,19 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
// Log authentication success
|
||||
const authEvent = new AuthEvent(authType, 'manual', 'success');
|
||||
logAuth(config, authEvent);
|
||||
|
||||
// Show success message
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Authenticated successfully with {{authType}} credentials.', {
|
||||
authType,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
[settings, handleAuthFailure, config],
|
||||
[settings, handleAuthFailure, config, addItem],
|
||||
);
|
||||
|
||||
const performAuth = useCallback(
|
||||
@@ -211,7 +230,13 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
)
|
||||
) {
|
||||
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]);
|
||||
|
||||
@@ -8,10 +8,13 @@ import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType, type HistoryItemAbout } from '../types.js';
|
||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
description: 'show version info',
|
||||
get description() {
|
||||
return t('show version info');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
@@ -9,15 +9,20 @@ import {
|
||||
type SlashCommand,
|
||||
type OpenDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const agentsCommand: SlashCommand = {
|
||||
name: 'agents',
|
||||
description: 'Manage subagents for specialized task delegation.',
|
||||
get description() {
|
||||
return t('Manage subagents for specialized task delegation.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'manage',
|
||||
description: 'Manage existing subagents (view, edit, delete).',
|
||||
get description() {
|
||||
return t('Manage existing subagents (view, edit, delete).');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
@@ -26,7 +31,9 @@ export const agentsCommand: SlashCommand = {
|
||||
},
|
||||
{
|
||||
name: 'create',
|
||||
description: 'Create a new subagent with guided setup.',
|
||||
get description() {
|
||||
return t('Create a new subagent with guided setup.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
|
||||
@@ -10,10 +10,13 @@ import type {
|
||||
OpenDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const approvalModeCommand: SlashCommand = {
|
||||
name: 'approval-mode',
|
||||
description: 'View or change the approval mode for tool usage',
|
||||
get description() {
|
||||
return t('View or change the approval mode for tool usage');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
_context: CommandContext,
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
description: 'change the auth method',
|
||||
get description() {
|
||||
return t('change the auth method');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
|
||||
@@ -16,10 +16,13 @@ import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
name: 'bug',
|
||||
description: 'submit a bug report',
|
||||
get description() {
|
||||
return t('submit a bug report');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const bugDescription = (args || '').trim();
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
@@ -20,6 +19,7 @@ import path from 'node:path';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { Content } from '@google/genai';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ChatDetail {
|
||||
name: string;
|
||||
@@ -67,7 +67,9 @@ const getSavedChatTags = async (
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List saved conversation checkpoints',
|
||||
get description() {
|
||||
return t('List saved conversation checkpoints');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context): Promise<MessageActionReturn> => {
|
||||
const chatDetails = await getSavedChatTags(context, false);
|
||||
@@ -75,7 +77,7 @@ const listCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No saved conversation checkpoints found.',
|
||||
content: t('No saved conversation checkpoints found.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +85,7 @@ const listCommand: SlashCommand = {
|
||||
...chatDetails.map((chat) => chat.name.length),
|
||||
);
|
||||
|
||||
let message = 'List of saved conversations:\n\n';
|
||||
let message = t('List of saved conversations:') + '\n\n';
|
||||
for (const chat of chatDetails) {
|
||||
const paddedName = chat.name.padEnd(maxNameLength, ' ');
|
||||
const isoString = chat.mtime.toISOString();
|
||||
@@ -91,7 +93,7 @@ const listCommand: SlashCommand = {
|
||||
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
|
||||
message += ` - ${paddedName} (saved on ${formattedDate})\n`;
|
||||
}
|
||||
message += `\nNote: Newest last, oldest first`;
|
||||
message += `\n${t('Note: Newest last, oldest first')}`;
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -102,8 +104,11 @@ const listCommand: SlashCommand = {
|
||||
|
||||
const saveCommand: SlashCommand = {
|
||||
name: 'save',
|
||||
description:
|
||||
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
||||
get description() {
|
||||
return t(
|
||||
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
|
||||
const tag = args.trim();
|
||||
@@ -111,7 +116,7 @@ const saveCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Missing tag. Usage: /chat save <tag>',
|
||||
content: t('Missing tag. Usage: /chat save <tag>'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,9 +131,12 @@ const saveCommand: SlashCommand = {
|
||||
prompt: React.createElement(
|
||||
Text,
|
||||
null,
|
||||
'A checkpoint with the tag ',
|
||||
React.createElement(Text, { color: theme.text.accent }, tag),
|
||||
' already exists. Do you want to overwrite it?',
|
||||
t(
|
||||
'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?',
|
||||
{
|
||||
tag,
|
||||
},
|
||||
),
|
||||
),
|
||||
originalInvocation: {
|
||||
raw: context.invocation?.raw || `/chat save ${tag}`,
|
||||
@@ -142,7 +150,7 @@ const saveCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No chat client available to save conversation.',
|
||||
content: t('No chat client available to save conversation.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,13 +160,15 @@ const saveCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation checkpoint saved with tag: ${decodeTagName(tag)}.`,
|
||||
content: t('Conversation checkpoint saved with tag: {{tag}}.', {
|
||||
tag: decodeTagName(tag),
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found to save.',
|
||||
content: t('No conversation found to save.'),
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -167,8 +177,11 @@ const saveCommand: SlashCommand = {
|
||||
const resumeCommand: SlashCommand = {
|
||||
name: 'resume',
|
||||
altNames: ['load'],
|
||||
description:
|
||||
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
|
||||
get description() {
|
||||
return t(
|
||||
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args) => {
|
||||
const tag = args.trim();
|
||||
@@ -176,7 +189,7 @@ const resumeCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Missing tag. Usage: /chat resume <tag>',
|
||||
content: t('Missing tag. Usage: /chat resume <tag>'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +201,9 @@ const resumeCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`,
|
||||
content: t('No saved checkpoint found with tag: {{tag}}.', {
|
||||
tag: decodeTagName(tag),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -237,7 +252,9 @@ const resumeCommand: SlashCommand = {
|
||||
|
||||
const deleteCommand: SlashCommand = {
|
||||
name: 'delete',
|
||||
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
|
||||
get description() {
|
||||
return t('Delete a conversation checkpoint. Usage: /chat delete <tag>');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args): Promise<MessageActionReturn> => {
|
||||
const tag = args.trim();
|
||||
@@ -245,7 +262,7 @@ const deleteCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Missing tag. Usage: /chat delete <tag>',
|
||||
content: t('Missing tag. Usage: /chat delete <tag>'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,13 +274,17 @@ const deleteCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`,
|
||||
content: t("Conversation checkpoint '{{tag}}' has been deleted.", {
|
||||
tag: decodeTagName(tag),
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`,
|
||||
content: t("Error: No checkpoint found with tag '{{tag}}'.", {
|
||||
tag: decodeTagName(tag),
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -309,8 +330,11 @@ export function serializeHistoryToMarkdown(history: Content[]): string {
|
||||
|
||||
const shareCommand: SlashCommand = {
|
||||
name: 'share',
|
||||
description:
|
||||
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
|
||||
get description() {
|
||||
return t(
|
||||
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args): Promise<MessageActionReturn> => {
|
||||
let filePathArg = args.trim();
|
||||
@@ -324,7 +348,7 @@ const shareCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Invalid file format. Only .md and .json are supported.',
|
||||
content: t('Invalid file format. Only .md and .json are supported.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,7 +357,7 @@ const shareCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No chat client available to share conversation.',
|
||||
content: t('No chat client available to share conversation.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -346,7 +370,7 @@ const shareCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found to share.',
|
||||
content: t('No conversation found to share.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,14 +386,18 @@ const shareCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation shared to ${filePath}`,
|
||||
content: t('Conversation shared to {{filePath}}', {
|
||||
filePath,
|
||||
}),
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Error sharing conversation: ${errorMessage}`,
|
||||
content: t('Error sharing conversation: {{error}}', {
|
||||
error: errorMessage,
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -377,7 +405,9 @@ const shareCommand: SlashCommand = {
|
||||
|
||||
export const chatCommand: SlashCommand = {
|
||||
name: 'chat',
|
||||
description: 'Manage conversation history.',
|
||||
get description() {
|
||||
return t('Manage conversation history.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
listCommand,
|
||||
|
||||
@@ -7,21 +7,24 @@
|
||||
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
description: 'clear the screen and conversation history',
|
||||
get description() {
|
||||
return t('clear the screen and conversation history');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, _args) => {
|
||||
const geminiClient = context.services.config?.getGeminiClient();
|
||||
|
||||
if (geminiClient) {
|
||||
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||
context.ui.setDebugMessage(t('Clearing terminal and resetting chat.'));
|
||||
// If resetChat fails, the exception will propagate and halt the command,
|
||||
// which is the correct behavior to signal a failure to the user.
|
||||
await geminiClient.resetChat();
|
||||
} else {
|
||||
context.ui.setDebugMessage('Clearing terminal.');
|
||||
context.ui.setDebugMessage(t('Clearing terminal.'));
|
||||
}
|
||||
|
||||
uiTelemetryService.setLastPromptTokenCount(0);
|
||||
|
||||
@@ -8,11 +8,14 @@ import type { HistoryItemCompression } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const compressCommand: SlashCommand = {
|
||||
name: 'compress',
|
||||
altNames: ['summarize'],
|
||||
description: 'Compresses the context by replacing it with a summary.',
|
||||
get description() {
|
||||
return t('Compresses the context by replacing it with a summary.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const { ui } = context;
|
||||
@@ -20,7 +23,7 @@ export const compressCommand: SlashCommand = {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Already compressing, wait for previous request to complete',
|
||||
text: t('Already compressing, wait for previous request to complete'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -60,7 +63,7 @@ export const compressCommand: SlashCommand = {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to compress chat history.',
|
||||
text: t('Failed to compress chat history.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -69,9 +72,9 @@ export const compressCommand: SlashCommand = {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to compress chat history: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`,
|
||||
text: t('Failed to compress chat history: {{error}}', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -7,10 +7,13 @@
|
||||
import { copyToClipboard } from '../utils/commandUtils.js';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const copyCommand: SlashCommand = {
|
||||
name: 'copy',
|
||||
description: 'Copy the last result or code snippet to clipboard',
|
||||
get description() {
|
||||
return t('Copy the last result or code snippet to clipboard');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
||||
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MessageType } from '../types.js';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export function expandHomeDir(p: string): string {
|
||||
if (!p) {
|
||||
@@ -27,13 +28,18 @@ export function expandHomeDir(p: string): string {
|
||||
export const directoryCommand: SlashCommand = {
|
||||
name: 'directory',
|
||||
altNames: ['dir'],
|
||||
description: 'Manage workspace directories',
|
||||
get description() {
|
||||
return t('Manage workspace directories');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'add',
|
||||
description:
|
||||
'Add directories to the workspace. Use comma to separate multiple paths',
|
||||
get description() {
|
||||
return t(
|
||||
'Add directories to the workspace. Use comma to separate multiple paths',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const {
|
||||
@@ -46,7 +52,7 @@ export const directoryCommand: SlashCommand = {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Configuration is not available.',
|
||||
text: t('Configuration is not available.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -63,7 +69,7 @@ export const directoryCommand: SlashCommand = {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Please provide at least one path to add.',
|
||||
text: t('Please provide at least one path to add.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -74,8 +80,9 @@ export const directoryCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message' as const,
|
||||
messageType: 'error' as const,
|
||||
content:
|
||||
content: t(
|
||||
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,7 +95,12 @@ export const directoryCommand: SlashCommand = {
|
||||
added.push(pathToAdd.trim());
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
|
||||
errors.push(
|
||||
t("Error adding '{{path}}': {{error}}", {
|
||||
path: pathToAdd.trim(),
|
||||
error: error.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +129,21 @@ export const directoryCommand: SlashCommand = {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
||||
text: t(
|
||||
'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}',
|
||||
{
|
||||
directories: added.join('\n- '),
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
errors.push(
|
||||
t('Error refreshing memory: {{error}}', {
|
||||
error: (error as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (added.length > 0) {
|
||||
@@ -133,7 +154,9 @@ export const directoryCommand: SlashCommand = {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
|
||||
text: t('Successfully added directories:\n- {{directories}}', {
|
||||
directories: added.join('\n- '),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -150,7 +173,9 @@ export const directoryCommand: SlashCommand = {
|
||||
},
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show all directories in the workspace',
|
||||
get description() {
|
||||
return t('Show all directories in the workspace');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
const {
|
||||
@@ -161,7 +186,7 @@ export const directoryCommand: SlashCommand = {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Configuration is not available.',
|
||||
text: t('Configuration is not available.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -173,7 +198,9 @@ export const directoryCommand: SlashCommand = {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Current workspace directories:\n${directoryList}`,
|
||||
text: t('Current workspace directories:\n{{directories}}', {
|
||||
directories: directoryList,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -12,19 +12,28 @@ import {
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { t, getCurrentLanguage } from '../../i18n/index.js';
|
||||
|
||||
export const docsCommand: SlashCommand = {
|
||||
name: 'docs',
|
||||
description: 'open full Qwen Code documentation in your browser',
|
||||
get description() {
|
||||
return t('open full Qwen Code documentation in your browser');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
|
||||
const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en';
|
||||
const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`;
|
||||
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
|
||||
text: t(
|
||||
'Please open the following URL in your browser to view the documentation:\n{{url}}',
|
||||
{
|
||||
url: docsUrl,
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -32,7 +41,9 @@ export const docsCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Opening documentation in your browser: ${docsUrl}`,
|
||||
text: t('Opening documentation in your browser: {{url}}', {
|
||||
url: docsUrl,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
type OpenDialogActionReturn,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const editorCommand: SlashCommand = {
|
||||
name: 'editor',
|
||||
description: 'set external editor preference',
|
||||
get description() {
|
||||
return t('set external editor preference');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
async function listAction(context: CommandContext) {
|
||||
context.ui.addItem(
|
||||
@@ -131,14 +132,18 @@ async function updateAction(context: CommandContext, args: string) {
|
||||
|
||||
const listExtensionsCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List active extensions',
|
||||
get description() {
|
||||
return t('List active extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
const updateExtensionsCommand: SlashCommand = {
|
||||
name: 'update',
|
||||
description: 'Update extensions. Usage: update <extension-names>|--all',
|
||||
get description() {
|
||||
return t('Update extensions. Usage: update <extension-names>|--all');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: updateAction,
|
||||
completion: async (context, partialArg) => {
|
||||
@@ -158,7 +163,9 @@ const updateExtensionsCommand: SlashCommand = {
|
||||
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions',
|
||||
description: 'Manage extensions',
|
||||
get description() {
|
||||
return t('Manage extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listExtensionsCommand, updateExtensionsCommand],
|
||||
action: (context, args) =>
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType, type HistoryItemHelp } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const helpCommand: SlashCommand = {
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'for help on Qwen Code',
|
||||
get description() {
|
||||
return t('for help on Qwen Code');
|
||||
},
|
||||
action: async (context) => {
|
||||
const helpItem: Omit<HistoryItemHelp, 'id'> = {
|
||||
type: MessageType.HELP,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
function getIdeStatusMessage(ideClient: IdeClient): {
|
||||
messageType: 'info' | 'error';
|
||||
@@ -138,27 +139,35 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
if (!currentIDE) {
|
||||
return {
|
||||
name: 'ide',
|
||||
description: 'manage IDE integration',
|
||||
get description() {
|
||||
return t('manage IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): SlashCommandActionReturn =>
|
||||
({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.`,
|
||||
content: t(
|
||||
'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.',
|
||||
),
|
||||
}) as const,
|
||||
};
|
||||
}
|
||||
|
||||
const ideSlashCommand: SlashCommand = {
|
||||
name: 'ide',
|
||||
description: 'manage IDE integration',
|
||||
get description() {
|
||||
return t('manage IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [],
|
||||
};
|
||||
|
||||
const statusCommand: SlashCommand = {
|
||||
name: 'status',
|
||||
description: 'check status of IDE integration',
|
||||
get description() {
|
||||
return t('check status of IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (): Promise<SlashCommandActionReturn> => {
|
||||
const { messageType, content } =
|
||||
@@ -173,7 +182,12 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
|
||||
const installCommand: SlashCommand = {
|
||||
name: 'install',
|
||||
description: `install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`,
|
||||
get description() {
|
||||
const ideName = ideClient.getDetectedIdeDisplayName() ?? 'IDE';
|
||||
return t('install required IDE companion for {{ideName}}', {
|
||||
ideName,
|
||||
});
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const installer = getIdeInstaller(currentIDE);
|
||||
@@ -246,7 +260,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'enable',
|
||||
description: 'enable IDE integration',
|
||||
get description() {
|
||||
return t('enable IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(
|
||||
@@ -268,7 +284,9 @@ export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
|
||||
const disableCommand: SlashCommand = {
|
||||
name: 'disable',
|
||||
description: 'disable IDE integration',
|
||||
get description() {
|
||||
return t('disable IDE integration');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext) => {
|
||||
context.services.settings.setValue(
|
||||
|
||||
@@ -15,10 +15,13 @@ import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
|
||||
import { CommandKind } from './types.js';
|
||||
import { Text } from 'ink';
|
||||
import React from 'react';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const initCommand: SlashCommand = {
|
||||
name: 'init',
|
||||
description: 'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
get description() {
|
||||
return t('Analyzes the project and creates a tailored QWEN.md file.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
@@ -28,7 +31,7 @@ export const initCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Configuration not available.',
|
||||
content: t('Configuration not available.'),
|
||||
};
|
||||
}
|
||||
const targetDir = context.services.config.getTargetDir();
|
||||
|
||||
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';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
description: 'Authenticate with an OAuth-enabled MCP server',
|
||||
get description() {
|
||||
return t('Authenticate with an OAuth-enabled MCP server');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
@@ -40,7 +43,7 @@ const authCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,14 +59,14 @@ const authCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No MCP servers configured with OAuth authentication.',
|
||||
content: t('No MCP servers configured with OAuth authentication.'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth <server-name> to authenticate.`,
|
||||
content: `${t('MCP servers with OAuth authentication:')}\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\n${t('Use /mcp auth <server-name> to authenticate.')}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +75,7 @@ const authCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `MCP server '${serverName}' not found.`,
|
||||
content: t("MCP server '{{name}}' not found.", { name: serverName }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +92,12 @@ const authCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Starting OAuth authentication for MCP server '${serverName}'...`,
|
||||
text: t(
|
||||
"Starting OAuth authentication for MCP server '{{name}}'...",
|
||||
{
|
||||
name: serverName,
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -111,7 +119,12 @@ const authCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `✅ Successfully authenticated with MCP server '${serverName}'!`,
|
||||
text: t(
|
||||
"Successfully authenticated and refreshed tools for '{{name}}'.",
|
||||
{
|
||||
name: serverName,
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -122,7 +135,9 @@ const authCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Re-discovering tools from '${serverName}'...`,
|
||||
text: t("Re-discovering tools from '{{name}}'...", {
|
||||
name: serverName,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -140,13 +155,24 @@ const authCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
|
||||
content: t(
|
||||
"Successfully authenticated and refreshed tools for '{{name}}'.",
|
||||
{
|
||||
name: serverName,
|
||||
},
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
content: t(
|
||||
"Failed to authenticate with MCP server '{{name}}': {{error}}",
|
||||
{
|
||||
name: serverName,
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
||||
@@ -165,7 +191,9 @@ const authCommand: SlashCommand = {
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List configured MCP servers and tools',
|
||||
get description() {
|
||||
return t('List configured MCP servers and tools');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
@@ -176,7 +204,7 @@ const listCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -185,7 +213,7 @@ const listCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
content: t('Could not retrieve tool registry.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,7 +304,9 @@ const listCommand: SlashCommand = {
|
||||
|
||||
const refreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
description: 'Restarts MCP servers.',
|
||||
get description() {
|
||||
return t('Restarts MCP servers.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
@@ -286,7 +316,7 @@ const refreshCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,14 +325,14 @@ const refreshCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
content: t('Could not retrieve tool registry.'),
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Restarting MCP servers...',
|
||||
text: t('Restarting MCP servers...'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -324,8 +354,11 @@ const refreshCommand: SlashCommand = {
|
||||
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
description:
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
get description() {
|
||||
return t(
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, authCommand, refreshCommand],
|
||||
// Default action when no subcommand is provided
|
||||
|
||||
@@ -15,15 +15,20 @@ import fs from 'fs/promises';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
description: 'Commands for interacting with memory.',
|
||||
get description() {
|
||||
return t('Commands for interacting with memory.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show the current memory contents.',
|
||||
get description() {
|
||||
return t('Show the current memory contents.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const memoryContent = context.services.config?.getUserMemory() || '';
|
||||
@@ -31,8 +36,8 @@ export const memoryCommand: SlashCommand = {
|
||||
|
||||
const messageContent =
|
||||
memoryContent.length > 0
|
||||
? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
|
||||
: 'Memory is currently empty.';
|
||||
? `${t('Current memory content from {{count}} file(s):', { count: String(fileCount) })}\n\n---\n${memoryContent}\n---`
|
||||
: t('Memory is currently empty.');
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
@@ -45,7 +50,9 @@ export const memoryCommand: SlashCommand = {
|
||||
subCommands: [
|
||||
{
|
||||
name: '--project',
|
||||
description: 'Show project-level memory contents.',
|
||||
get description() {
|
||||
return t('Show project-level memory contents.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
try {
|
||||
@@ -57,8 +64,14 @@ export const memoryCommand: SlashCommand = {
|
||||
|
||||
const messageContent =
|
||||
memoryContent.trim().length > 0
|
||||
? `Project memory content from ${projectMemoryPath}:\n\n---\n${memoryContent}\n---`
|
||||
: 'Project memory is currently empty.';
|
||||
? t(
|
||||
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
|
||||
{
|
||||
path: projectMemoryPath,
|
||||
content: memoryContent,
|
||||
},
|
||||
)
|
||||
: t('Project memory is currently empty.');
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
@@ -71,7 +84,9 @@ export const memoryCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Project memory file not found or is currently empty.',
|
||||
text: t(
|
||||
'Project memory file not found or is currently empty.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -80,7 +95,9 @@ export const memoryCommand: SlashCommand = {
|
||||
},
|
||||
{
|
||||
name: '--global',
|
||||
description: 'Show global memory contents.',
|
||||
get description() {
|
||||
return t('Show global memory contents.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
try {
|
||||
@@ -96,8 +113,10 @@ export const memoryCommand: SlashCommand = {
|
||||
|
||||
const messageContent =
|
||||
globalMemoryContent.trim().length > 0
|
||||
? `Global memory content:\n\n---\n${globalMemoryContent}\n---`
|
||||
: 'Global memory is currently empty.';
|
||||
? t('Global memory content:\n\n---\n{{content}}\n---', {
|
||||
content: globalMemoryContent,
|
||||
})
|
||||
: t('Global memory is currently empty.');
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
@@ -110,7 +129,9 @@ export const memoryCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Global memory file not found or is currently empty.',
|
||||
text: t(
|
||||
'Global memory file not found or is currently empty.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -121,16 +142,20 @@ export const memoryCommand: SlashCommand = {
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
description:
|
||||
'Add content to the memory. Use --global for global memory or --project for project memory.',
|
||||
get description() {
|
||||
return t(
|
||||
'Add content to the memory. Use --global for global memory or --project for project memory.',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, args): SlashCommandActionReturn | void => {
|
||||
if (!args || args.trim() === '') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
content: t(
|
||||
'Usage: /memory add [--global|--project] <text to remember>',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,8 +175,9 @@ export const memoryCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
content: t(
|
||||
'Usage: /memory add [--global|--project] <text to remember>',
|
||||
),
|
||||
};
|
||||
} else {
|
||||
// No scope specified, will be handled by the tool
|
||||
@@ -162,8 +188,9 @@ export const memoryCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
content: t(
|
||||
'Usage: /memory add [--global|--project] <text to remember>',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,7 +198,10 @@ export const memoryCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Attempting to save to memory ${scopeText}: "${fact}"`,
|
||||
text: t('Attempting to save to memory {{scope}}: "{{fact}}"', {
|
||||
scope: scopeText,
|
||||
fact,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -185,21 +215,25 @@ export const memoryCommand: SlashCommand = {
|
||||
subCommands: [
|
||||
{
|
||||
name: '--project',
|
||||
description: 'Add content to project-level memory.',
|
||||
get description() {
|
||||
return t('Add content to project-level memory.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, args): SlashCommandActionReturn | void => {
|
||||
if (!args || args.trim() === '') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /memory add --project <text to remember>',
|
||||
content: t('Usage: /memory add --project <text to remember>'),
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Attempting to save to project memory: "${args.trim()}"`,
|
||||
text: t('Attempting to save to project memory: "{{text}}"', {
|
||||
text: args.trim(),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -213,21 +247,25 @@ export const memoryCommand: SlashCommand = {
|
||||
},
|
||||
{
|
||||
name: '--global',
|
||||
description: 'Add content to global memory.',
|
||||
get description() {
|
||||
return t('Add content to global memory.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, args): SlashCommandActionReturn | void => {
|
||||
if (!args || args.trim() === '') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /memory add --global <text to remember>',
|
||||
content: t('Usage: /memory add --global <text to remember>'),
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Attempting to save to global memory: "${args.trim()}"`,
|
||||
text: t('Attempting to save to global memory: "{{text}}"', {
|
||||
text: args.trim(),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -243,13 +281,15 @@ export const memoryCommand: SlashCommand = {
|
||||
},
|
||||
{
|
||||
name: 'refresh',
|
||||
description: 'Refresh the memory from the source.',
|
||||
get description() {
|
||||
return t('Refresh the memory from the source.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing memory from source files...',
|
||||
text: t('Refreshing memory from source files...'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -12,10 +12,13 @@ import type {
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const modelCommand: SlashCommand = {
|
||||
name: 'model',
|
||||
description: 'Switch the model for this session',
|
||||
get description() {
|
||||
return t('Switch the model for this session');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
@@ -36,7 +39,7 @@ export const modelCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Content generator configuration not available.',
|
||||
content: t('Content generator configuration not available.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +48,7 @@ export const modelCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Authentication type not available.',
|
||||
content: t('Authentication type not available.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +58,12 @@ export const modelCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `No models available for the current authentication type (${authType}).`,
|
||||
content: t(
|
||||
'No models available for the current authentication type ({{authType}}).',
|
||||
{
|
||||
authType,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const permissionsCommand: SlashCommand = {
|
||||
name: 'permissions',
|
||||
description: 'Manage folder trust settings',
|
||||
get description() {
|
||||
return t('Manage folder trust settings');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const quitConfirmCommand: SlashCommand = {
|
||||
name: 'quit-confirm',
|
||||
description: 'Show quit confirmation dialog',
|
||||
get description() {
|
||||
return t('Show quit confirmation dialog');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context) => {
|
||||
const now = Date.now();
|
||||
@@ -37,7 +40,9 @@ export const quitConfirmCommand: SlashCommand = {
|
||||
export const quitCommand: SlashCommand = {
|
||||
name: 'quit',
|
||||
altNames: ['exit'],
|
||||
description: 'exit the cli',
|
||||
get description() {
|
||||
return t('exit the cli');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context) => {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const settingsCommand: SlashCommand = {
|
||||
name: 'settings',
|
||||
description: 'View and edit Qwen Code settings',
|
||||
get description() {
|
||||
return t('View and edit Qwen Code settings');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const GITHUB_WORKFLOW_PATHS = [
|
||||
'gemini-dispatch/gemini-dispatch.yml',
|
||||
@@ -91,7 +92,9 @@ export async function updateGitignore(gitRepoRoot: string): Promise<void> {
|
||||
|
||||
export const setupGithubCommand: SlashCommand = {
|
||||
name: 'setup-github',
|
||||
description: 'Set up GitHub Actions',
|
||||
get description() {
|
||||
return t('Set up GitHub Actions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const statsCommand: SlashCommand = {
|
||||
name: 'stats',
|
||||
altNames: ['usage'],
|
||||
description: 'check session stats. Usage: /stats [model|tools]',
|
||||
get description() {
|
||||
return t('check session stats. Usage: /stats [model|tools]');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context: CommandContext) => {
|
||||
const now = new Date();
|
||||
@@ -25,7 +28,7 @@ export const statsCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Session start time is unavailable, cannot calculate stats.',
|
||||
text: t('Session start time is unavailable, cannot calculate stats.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -43,7 +46,9 @@ export const statsCommand: SlashCommand = {
|
||||
subCommands: [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Show model-specific usage statistics.',
|
||||
get description() {
|
||||
return t('Show model-specific usage statistics.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context: CommandContext) => {
|
||||
context.ui.addItem(
|
||||
@@ -56,7 +61,9 @@ export const statsCommand: SlashCommand = {
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
description: 'Show tool-specific usage statistics.',
|
||||
get description() {
|
||||
return t('Show tool-specific usage statistics.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context: CommandContext) => {
|
||||
context.ui.addItem(
|
||||
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
} from './types.js';
|
||||
import { getProjectSummaryPrompt } from '@qwen-code/qwen-code-core';
|
||||
import type { HistoryItemSummary } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const summaryCommand: SlashCommand = {
|
||||
name: 'summary',
|
||||
description:
|
||||
'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md',
|
||||
get description() {
|
||||
return t(
|
||||
'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
@@ -26,7 +30,7 @@ export const summaryCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +39,7 @@ export const summaryCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No chat client available to generate summary.',
|
||||
content: t('No chat client available to generate summary.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,15 +48,18 @@ export const summaryCommand: SlashCommand = {
|
||||
ui.addItem(
|
||||
{
|
||||
type: 'error' as const,
|
||||
text: 'Already generating summary, wait for previous request to complete',
|
||||
text: t(
|
||||
'Already generating summary, wait for previous request to complete',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
content: t(
|
||||
'Already generating summary, wait for previous request to complete',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,7 +72,7 @@ export const summaryCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found to summarize.',
|
||||
content: t('No conversation found to summarize.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,9 +178,12 @@ export const summaryCommand: SlashCommand = {
|
||||
ui.addItem(
|
||||
{
|
||||
type: 'error' as const,
|
||||
text: `❌ Failed to generate project context summary: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
text: `❌ ${t(
|
||||
'Failed to generate project context summary: {{error}}',
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -181,9 +191,9 @@ export const summaryCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to generate project context summary: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
content: t('Failed to generate project context summary: {{error}}', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { MessageActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { terminalSetup } from '../utils/terminalSetup.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Command to configure terminal keybindings for multiline input support.
|
||||
@@ -16,8 +17,11 @@ import { terminalSetup } from '../utils/terminalSetup.js';
|
||||
*/
|
||||
export const terminalSetupCommand: SlashCommand = {
|
||||
name: 'terminal-setup',
|
||||
description:
|
||||
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
|
||||
get description() {
|
||||
return t(
|
||||
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
|
||||
action: async (): Promise<MessageActionReturn> => {
|
||||
@@ -27,7 +31,8 @@ export const terminalSetupCommand: SlashCommand = {
|
||||
let content = result.message;
|
||||
if (result.requiresRestart) {
|
||||
content +=
|
||||
'\n\nPlease restart your terminal for the changes to take effect.';
|
||||
'\n\n' +
|
||||
t('Please restart your terminal for the changes to take effect.');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -38,7 +43,9 @@ export const terminalSetupCommand: SlashCommand = {
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
content: `Failed to configure terminal: ${error}`,
|
||||
content: t('Failed to configure terminal: {{error}}', {
|
||||
error: String(error),
|
||||
}),
|
||||
messageType: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const themeCommand: SlashCommand = {
|
||||
name: 'theme',
|
||||
description: 'change the theme',
|
||||
get description() {
|
||||
return t('change the theme');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItemToolsList } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const toolsCommand: SlashCommand = {
|
||||
name: 'tools',
|
||||
description: 'list available Qwen Code tools. Usage: /tools [desc]',
|
||||
get description() {
|
||||
return t('list available Qwen Code tools. Usage: /tools [desc]');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const subCommand = args?.trim();
|
||||
@@ -29,7 +32,7 @@ export const toolsCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Could not retrieve tool registry.',
|
||||
text: t('Could not retrieve tool registry.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const vimCommand: SlashCommand = {
|
||||
name: 'vim',
|
||||
description: 'toggle vim mode on/off',
|
||||
get description() {
|
||||
return t('toggle vim mode on/off');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, _args) => {
|
||||
const newVimState = await context.ui.toggleVimEnabled();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getFieldValue,
|
||||
type SystemInfoField,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
type AboutBoxProps = ExtendedSystemInfo;
|
||||
|
||||
@@ -30,7 +31,7 @@ export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
About Qwen Code
|
||||
{t('About Qwen Code')}
|
||||
</Text>
|
||||
</Box>
|
||||
{fields.map((field: SystemInfoField) => (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SettingScope } from '../../config/settings.js';
|
||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ApprovalModeDialogProps {
|
||||
/** Callback function when an approval mode is selected */
|
||||
@@ -33,15 +34,15 @@ interface ApprovalModeDialogProps {
|
||||
const formatModeDescription = (mode: ApprovalMode): string => {
|
||||
switch (mode) {
|
||||
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:
|
||||
return 'Require approval for file edits or shell commands';
|
||||
return t('Require approval for file edits or shell commands');
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
return 'Automatically approve file edits';
|
||||
return t('Automatically approve file edits');
|
||||
case ApprovalMode.YOLO:
|
||||
return 'Automatically approve all tools';
|
||||
return t('Automatically approve all tools');
|
||||
default:
|
||||
return `${mode} mode`;
|
||||
return t('{{mode}} mode', { mode });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,7 +135,8 @@ export function ApprovalModeDialog({
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Approval Mode Selection */}
|
||||
<Text bold={focusSection === 'mode'} wrap="truncate">
|
||||
{focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '}
|
||||
{focusSection === 'mode' ? '> ' : ' '}
|
||||
{t('Approval Mode')}{' '}
|
||||
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
@@ -167,15 +169,17 @@ export function ApprovalModeDialog({
|
||||
{showWorkspacePriorityWarning && (
|
||||
<>
|
||||
<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>
|
||||
<Box height={1} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select, Tab to change focus)
|
||||
{t('(Use Enter to select, Tab to change focus)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface AutoAcceptIndicatorProps {
|
||||
approvalMode: ApprovalMode;
|
||||
@@ -23,18 +24,18 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
textColor = theme.status.success;
|
||||
textContent = 'plan mode';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
textContent = t('plan mode');
|
||||
subText = ` ${t('(shift + tab to cycle)')}`;
|
||||
break;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
textColor = theme.status.warning;
|
||||
textContent = 'auto-accept edits';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
textContent = t('auto-accept edits');
|
||||
subText = ` ${t('(shift + tab to cycle)')}`;
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
textColor = theme.status.error;
|
||||
textContent = 'YOLO mode';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
textContent = t('YOLO mode');
|
||||
subText = ` ${t('(shift + tab to cycle)')}`;
|
||||
break;
|
||||
case ApprovalMode.DEFAULT:
|
||||
default:
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const Composer = () => {
|
||||
const config = useConfig();
|
||||
@@ -86,14 +87,16 @@ export const Composer = () => {
|
||||
)}
|
||||
{uiState.ctrlCPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl+C again to exit.
|
||||
{t('Press Ctrl+C again to exit.')}
|
||||
</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
Press Ctrl+D again to exit.
|
||||
{t('Press Ctrl+D again to exit.')}
|
||||
</Text>
|
||||
) : 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 && (
|
||||
<ContextSummaryDisplay
|
||||
@@ -151,8 +154,8 @@ export const Composer = () => {
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
placeholder={
|
||||
vimEnabled
|
||||
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
|
||||
: ' Type your message or @path/to/file'
|
||||
? ' ' + t("Press 'i' for INSERT mode and 'Esc' for NORMAL mode.")
|
||||
: ' ' + 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 { GeminiSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const ConfigInitDisplay = () => {
|
||||
const config = useConfig();
|
||||
const [message, setMessage] = useState('Initializing...');
|
||||
const [message, setMessage] = useState(t('Initializing...'));
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = (clients?: Map<string, McpClient>) => {
|
||||
if (!clients || clients.size === 0) {
|
||||
setMessage(`Initializing...`);
|
||||
setMessage(t('Initializing...'));
|
||||
return;
|
||||
}
|
||||
let connected = 0;
|
||||
@@ -28,7 +29,12 @@ export const ConfigInitDisplay = () => {
|
||||
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);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
@@ -50,9 +51,11 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
if (openFileCount === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${openFileCount} open file${
|
||||
openFileCount > 1 ? 's' : ''
|
||||
} (ctrl+g to view)`;
|
||||
const fileText =
|
||||
openFileCount === 1
|
||||
? t('{{count}} open file', { count: String(openFileCount) })
|
||||
: t('{{count}} open files', { count: String(openFileCount) });
|
||||
return `${fileText} ${t('(ctrl+g to view)')}`;
|
||||
})();
|
||||
|
||||
const geminiMdText = (() => {
|
||||
@@ -61,9 +64,15 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
}
|
||||
const allNamesTheSame = new Set(contextFileNames).size < 2;
|
||||
const name = allNamesTheSame ? contextFileNames[0] : 'context';
|
||||
return `${geminiMdFileCount} ${name} file${
|
||||
geminiMdFileCount > 1 ? 's' : ''
|
||||
}`;
|
||||
return geminiMdFileCount === 1
|
||||
? t('{{count}} {{name}} file', {
|
||||
count: String(geminiMdFileCount),
|
||||
name,
|
||||
})
|
||||
: t('{{count}} {{name}} files', {
|
||||
count: String(geminiMdFileCount),
|
||||
name,
|
||||
});
|
||||
})();
|
||||
|
||||
const mcpText = (() => {
|
||||
@@ -73,15 +82,27 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
|
||||
const parts = [];
|
||||
if (mcpServerCount > 0) {
|
||||
parts.push(
|
||||
`${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`,
|
||||
);
|
||||
const serverText =
|
||||
mcpServerCount === 1
|
||||
? t('{{count}} MCP server', { count: String(mcpServerCount) })
|
||||
: t('{{count}} MCP servers', { count: String(mcpServerCount) });
|
||||
parts.push(serverText);
|
||||
}
|
||||
|
||||
if (blockedMcpServerCount > 0) {
|
||||
let blockedText = `${blockedMcpServerCount} Blocked`;
|
||||
let blockedText = t('{{count}} Blocked', {
|
||||
count: String(blockedMcpServerCount),
|
||||
});
|
||||
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);
|
||||
}
|
||||
@@ -89,9 +110,9 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
// Add ctrl+t hint when MCP servers are available
|
||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||
if (showToolDescriptions) {
|
||||
text += ' (ctrl+t to toggle)';
|
||||
text += ` ${t('(ctrl+t to toggle)')}`;
|
||||
} else {
|
||||
text += ' (ctrl+t to view)';
|
||||
text += ` ${t('(ctrl+t to view)')}`;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
@@ -102,7 +123,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>Using:</Text>
|
||||
<Text color={theme.text.secondary}>{t('Using:')}</Text>
|
||||
{summaryParts.map((part, index) => (
|
||||
<Text key={index} color={theme.text.secondary}>
|
||||
{' '}- {part}
|
||||
@@ -115,7 +136,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
Using: {summaryParts.join(' | ')}
|
||||
{t('Using:')} {summaryParts.join(' | ')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SettingScope } from '../../config/settings.js';
|
||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||
import { isEditorAvailable } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface EditorDialogProps {
|
||||
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
|
||||
@@ -66,12 +67,16 @@ export function EditorSettingsDialog({
|
||||
|
||||
const scopeItems = [
|
||||
{
|
||||
label: 'User Settings',
|
||||
get label() {
|
||||
return t('User Settings');
|
||||
},
|
||||
value: SettingScope.User,
|
||||
key: SettingScope.User,
|
||||
},
|
||||
{
|
||||
label: 'Workspace Settings',
|
||||
get label() {
|
||||
return t('Workspace Settings');
|
||||
},
|
||||
value: SettingScope.Workspace,
|
||||
key: SettingScope.Workspace,
|
||||
},
|
||||
@@ -145,7 +150,8 @@ export function EditorSettingsDialog({
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={focusedSection === 'scope'}>
|
||||
{focusedSection === 'scope' ? '> ' : ' '}Apply To
|
||||
{focusedSection === 'scope' ? '> ' : ' '}
|
||||
{t('Apply To')}
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type SlashCommand, CommandKind } from '../commands/types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface Help {
|
||||
commands: readonly SlashCommand[];
|
||||
@@ -23,46 +24,41 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
>
|
||||
{/* Basics */}
|
||||
<Text bold color={theme.text.primary}>
|
||||
Basics:
|
||||
{t('Basics:')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Add context
|
||||
{t('Add context')}
|
||||
</Text>
|
||||
: Use{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
@
|
||||
</Text>{' '}
|
||||
to specify files for context (e.g.,{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
@src/myFile.ts
|
||||
</Text>
|
||||
) to target specific files or folders.
|
||||
:{' '}
|
||||
{t(
|
||||
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.',
|
||||
{
|
||||
symbol: t('@'),
|
||||
example: t('@src/myFile.ts'),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Shell mode
|
||||
{t('Shell mode')}
|
||||
</Text>
|
||||
: Execute shell commands via{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
!
|
||||
</Text>{' '}
|
||||
(e.g.,{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
!npm run start
|
||||
</Text>
|
||||
) or use natural language (e.g.{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
start server
|
||||
</Text>
|
||||
).
|
||||
:{' '}
|
||||
{t(
|
||||
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).',
|
||||
{
|
||||
symbol: t('!'),
|
||||
example1: t('!npm run start'),
|
||||
example2: t('start server'),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Commands */}
|
||||
<Text bold color={theme.text.primary}>
|
||||
Commands:
|
||||
{t('Commands:')}
|
||||
</Text>
|
||||
{commands
|
||||
.filter((command) => command.description && !command.hidden)
|
||||
@@ -97,81 +93,81 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
{' '}
|
||||
!{' '}
|
||||
</Text>
|
||||
- shell command
|
||||
- {t('shell command')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={theme.text.secondary}>[MCP]</Text> - Model Context Protocol
|
||||
command (from external servers)
|
||||
<Text color={theme.text.secondary}>[MCP]</Text> -{' '}
|
||||
{t('Model Context Protocol command (from external servers)')}
|
||||
</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Shortcuts */}
|
||||
<Text bold color={theme.text.primary}>
|
||||
Keyboard Shortcuts:
|
||||
{t('Keyboard Shortcuts:')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Alt+Left/Right
|
||||
</Text>{' '}
|
||||
- Jump through words in the input
|
||||
- {t('Jump through words in the input')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Ctrl+C
|
||||
</Text>{' '}
|
||||
- Close dialogs, cancel requests, or quit application
|
||||
- {t('Close dialogs, cancel requests, or quit application')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
|
||||
</Text>{' '}
|
||||
-{' '}
|
||||
{process.platform === 'linux'
|
||||
? '- New line (Alt+Enter works for certain linux distros)'
|
||||
: '- New line'}
|
||||
? t('New line (Alt+Enter works for certain linux distros)')
|
||||
: t('New line')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Ctrl+L
|
||||
</Text>{' '}
|
||||
- Clear the screen
|
||||
- {t('Clear the screen')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
|
||||
</Text>{' '}
|
||||
- Open input in external editor
|
||||
- {t('Open input in external editor')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Enter
|
||||
</Text>{' '}
|
||||
- Send message
|
||||
- {t('Send message')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Esc
|
||||
</Text>{' '}
|
||||
- Cancel operation / Clear input (double press)
|
||||
- {t('Cancel operation / Clear input (double press)')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Shift+Tab
|
||||
</Text>{' '}
|
||||
- Cycle approval modes
|
||||
- {t('Cycle approval modes')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Up/Down
|
||||
</Text>{' '}
|
||||
- Cycle through your prompt history
|
||||
- {t('Cycle through your prompt history')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text color={theme.text.primary}>
|
||||
For a full list of shortcuts, see{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
docs/keyboard-shortcuts.md
|
||||
</Text>
|
||||
{t('For a full list of shortcuts, see {{docPath}}', {
|
||||
docPath: t('docs/keyboard-shortcuts.md'),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -164,11 +164,6 @@ describe('InputPrompt', () => {
|
||||
setActiveSuggestionIndex: vi.fn(),
|
||||
setShowSuggestions: vi.fn(),
|
||||
handleAutocomplete: vi.fn(),
|
||||
promptCompletion: {
|
||||
text: '',
|
||||
accept: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
};
|
||||
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
|
||||
|
||||
@@ -215,6 +210,7 @@ describe('InputPrompt', () => {
|
||||
inputWidth: 80,
|
||||
suggestionsWidth: 80,
|
||||
focus: true,
|
||||
placeholder: ' Type your message or @path/to/file',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1955,7 +1951,7 @@ describe('InputPrompt', () => {
|
||||
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;
|
||||
const longValue = 'l'.repeat(200);
|
||||
|
||||
|
||||
@@ -12,9 +12,8 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { logicalPosToOffset } from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
|
||||
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||||
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
parseInputForHighlighting,
|
||||
buildSegmentsForVisualSlice,
|
||||
} from '../utils/highlight.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
clipboardHasImage,
|
||||
saveClipboardImage,
|
||||
@@ -89,9 +89,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
config,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
placeholder = ' Type your message or @path/to/file',
|
||||
placeholder,
|
||||
focus = true,
|
||||
inputWidth,
|
||||
suggestionsWidth,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
@@ -526,16 +525,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Tab key for ghost text acceptance
|
||||
if (
|
||||
key.name === 'tab' &&
|
||||
!completion.showSuggestions &&
|
||||
completion.promptCompletion.text
|
||||
) {
|
||||
completion.promptCompletion.accept();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setCommandSearchActive(true);
|
||||
@@ -657,18 +646,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
|
||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||
if (
|
||||
completion.promptCompletion.text &&
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.ctrl &&
|
||||
!key.meta
|
||||
) {
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
},
|
||||
[
|
||||
focus,
|
||||
@@ -703,118 +680,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.visualCursor;
|
||||
const scrollVisualRow = buffer.visualScrollRow;
|
||||
|
||||
const getGhostTextLines = useCallback(() => {
|
||||
if (
|
||||
!completion.promptCompletion.text ||
|
||||
!buffer.text ||
|
||||
!completion.promptCompletion.text.startsWith(buffer.text)
|
||||
) {
|
||||
return { inlineGhost: '', additionalLines: [] };
|
||||
}
|
||||
|
||||
const ghostSuffix = completion.promptCompletion.text.slice(
|
||||
buffer.text.length,
|
||||
);
|
||||
if (!ghostSuffix) {
|
||||
return { inlineGhost: '', additionalLines: [] };
|
||||
}
|
||||
|
||||
const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
|
||||
const cursorCol = buffer.cursor[1];
|
||||
|
||||
const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
|
||||
const usedWidth = stringWidth(textBeforeCursor);
|
||||
const remainingWidth = Math.max(0, inputWidth - usedWidth);
|
||||
|
||||
const ghostTextLinesRaw = ghostSuffix.split('\n');
|
||||
const firstLineRaw = ghostTextLinesRaw.shift() || '';
|
||||
|
||||
let inlineGhost = '';
|
||||
let remainingFirstLine = '';
|
||||
|
||||
if (stringWidth(firstLineRaw) <= remainingWidth) {
|
||||
inlineGhost = firstLineRaw;
|
||||
} else {
|
||||
const words = firstLineRaw.split(' ');
|
||||
let currentLine = '';
|
||||
let wordIdx = 0;
|
||||
for (const word of words) {
|
||||
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (stringWidth(prospectiveLine) > remainingWidth) {
|
||||
break;
|
||||
}
|
||||
currentLine = prospectiveLine;
|
||||
wordIdx++;
|
||||
}
|
||||
inlineGhost = currentLine;
|
||||
if (words.length > wordIdx) {
|
||||
remainingFirstLine = words.slice(wordIdx).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
const linesToWrap = [];
|
||||
if (remainingFirstLine) {
|
||||
linesToWrap.push(remainingFirstLine);
|
||||
}
|
||||
linesToWrap.push(...ghostTextLinesRaw);
|
||||
const remainingGhostText = linesToWrap.join('\n');
|
||||
|
||||
const additionalLines: string[] = [];
|
||||
if (remainingGhostText) {
|
||||
const textLines = remainingGhostText.split('\n');
|
||||
for (const textLine of textLines) {
|
||||
const words = textLine.split(' ');
|
||||
let currentLine = '';
|
||||
|
||||
for (const word of words) {
|
||||
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
const prospectiveWidth = stringWidth(prospectiveLine);
|
||||
|
||||
if (prospectiveWidth > inputWidth) {
|
||||
if (currentLine) {
|
||||
additionalLines.push(currentLine);
|
||||
}
|
||||
|
||||
let wordToProcess = word;
|
||||
while (stringWidth(wordToProcess) > inputWidth) {
|
||||
let part = '';
|
||||
const wordCP = toCodePoints(wordToProcess);
|
||||
let partWidth = 0;
|
||||
let splitIndex = 0;
|
||||
for (let i = 0; i < wordCP.length; i++) {
|
||||
const char = wordCP[i];
|
||||
const charWidth = stringWidth(char);
|
||||
if (partWidth + charWidth > inputWidth) {
|
||||
break;
|
||||
}
|
||||
part += char;
|
||||
partWidth += charWidth;
|
||||
splitIndex = i + 1;
|
||||
}
|
||||
additionalLines.push(part);
|
||||
wordToProcess = cpSlice(wordToProcess, splitIndex);
|
||||
}
|
||||
currentLine = wordToProcess;
|
||||
} else {
|
||||
currentLine = prospectiveLine;
|
||||
}
|
||||
}
|
||||
if (currentLine) {
|
||||
additionalLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { inlineGhost, additionalLines };
|
||||
}, [
|
||||
completion.promptCompletion.text,
|
||||
buffer.text,
|
||||
buffer.lines,
|
||||
buffer.cursor,
|
||||
inputWidth,
|
||||
]);
|
||||
|
||||
const { inlineGhost, additionalLines } = getGhostTextLines();
|
||||
const getActiveCompletion = () => {
|
||||
if (commandSearchActive) return commandSearchCompletion;
|
||||
if (reverseSearchActive) return reverseSearchCompletion;
|
||||
@@ -833,13 +698,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
let statusText = '';
|
||||
if (shellModeActive) {
|
||||
statusColor = theme.ui.symbol;
|
||||
statusText = 'Shell mode';
|
||||
statusText = t('Shell mode');
|
||||
} else if (showYoloStyling) {
|
||||
statusColor = theme.status.error;
|
||||
statusText = 'YOLO mode';
|
||||
statusText = t('YOLO mode');
|
||||
} else if (showAutoAcceptStyling) {
|
||||
statusColor = theme.status.warning;
|
||||
statusText = 'Accepting edits';
|
||||
statusText = t('Accepting edits');
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -887,134 +752,96 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
linesToRender
|
||||
.map((lineText, visualIdxInRenderedSet) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
linesToRender.map((lineText, visualIdxInRenderedSet) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
|
||||
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
);
|
||||
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
);
|
||||
|
||||
const visualStart = logicalStartCol;
|
||||
const visualEnd = logicalStartCol + cpLen(lineText);
|
||||
const segments = buildSegmentsForVisualSlice(
|
||||
tokens,
|
||||
visualStart,
|
||||
visualEnd,
|
||||
);
|
||||
const visualStart = logicalStartCol;
|
||||
const visualEnd = logicalStartCol + cpLen(lineText);
|
||||
const segments = buildSegmentsForVisualSlice(
|
||||
tokens,
|
||||
visualStart,
|
||||
visualEnd,
|
||||
);
|
||||
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight =
|
||||
cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
seg.text,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
seg.text,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
seg.text,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
seg.text,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
seg.text,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
}
|
||||
charCount = segEnd;
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
charCount = segEnd;
|
||||
}
|
||||
|
||||
const showCursorBeforeGhost =
|
||||
focus &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost &&
|
||||
(showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
additionalLines.map((ghostLine, index) => {
|
||||
const padding = Math.max(
|
||||
0,
|
||||
inputWidth - stringWidth(ghostLine),
|
||||
);
|
||||
return (
|
||||
<Text
|
||||
key={`ghost-line-${index}`}
|
||||
color={theme.text.secondary}
|
||||
>
|
||||
{ghostLine}
|
||||
{' '.repeat(padding)}
|
||||
</Text>
|
||||
);
|
||||
}),
|
||||
)
|
||||
});
|
||||
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>{renderedLine}</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
@@ -40,7 +41,12 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
|
||||
const cancelAndTimerContent =
|
||||
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;
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getAvailableModelsForAuthType,
|
||||
MAINLINE_CODER,
|
||||
} from '../models/availableModels.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
@@ -87,7 +88,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Select Model</Text>
|
||||
<Text bold>{t('Select Model')}</Text>
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={MODEL_OPTIONS}
|
||||
@@ -97,7 +98,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
/>
|
||||
</Box>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../utils/computeStats.js';
|
||||
import type { ModelMetrics } from '../contexts/SessionContext.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const METRIC_COL_WIDTH = 28;
|
||||
const MODEL_COL_WIDTH = 22;
|
||||
@@ -65,7 +66,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
paddingX={2}
|
||||
>
|
||||
<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>
|
||||
</Box>
|
||||
);
|
||||
@@ -94,7 +95,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
paddingX={2}
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
Model Stats For Nerds
|
||||
{t('Model Stats For Nerds')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
|
||||
@@ -102,7 +103,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
<Box>
|
||||
<Box width={METRIC_COL_WIDTH}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Metric
|
||||
{t('Metric')}
|
||||
</Text>
|
||||
</Box>
|
||||
{modelNames.map((name) => (
|
||||
@@ -125,13 +126,13 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* API Section */}
|
||||
<StatRow title="API" values={[]} isSection />
|
||||
<StatRow title={t('API')} values={[]} isSection />
|
||||
<StatRow
|
||||
title="Requests"
|
||||
title={t('Requests')}
|
||||
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
|
||||
/>
|
||||
<StatRow
|
||||
title="Errors"
|
||||
title={t('Errors')}
|
||||
values={getModelValues((m) => {
|
||||
const errorRate = calculateErrorRate(m);
|
||||
return (
|
||||
@@ -146,7 +147,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
})}
|
||||
/>
|
||||
<StatRow
|
||||
title="Avg Latency"
|
||||
title={t('Avg Latency')}
|
||||
values={getModelValues((m) => {
|
||||
const avgLatency = calculateAverageLatency(m);
|
||||
return formatDuration(avgLatency);
|
||||
@@ -156,9 +157,9 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
<Box height={1} />
|
||||
|
||||
{/* Tokens Section */}
|
||||
<StatRow title="Tokens" values={[]} isSection />
|
||||
<StatRow title={t('Tokens')} values={[]} isSection />
|
||||
<StatRow
|
||||
title="Total"
|
||||
title={t('Total')}
|
||||
values={getModelValues((m) => (
|
||||
<Text color={theme.status.warning}>
|
||||
{m.tokens.total.toLocaleString()}
|
||||
@@ -166,13 +167,13 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
))}
|
||||
/>
|
||||
<StatRow
|
||||
title="Prompt"
|
||||
title={t('Prompt')}
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
|
||||
/>
|
||||
{hasCached && (
|
||||
<StatRow
|
||||
title="Cached"
|
||||
title={t('Cached')}
|
||||
isSubtle
|
||||
values={getModelValues((m) => {
|
||||
const cacheHitRate = calculateCacheHitRate(m);
|
||||
@@ -186,20 +187,20 @@ export const ModelStatsDisplay: React.FC = () => {
|
||||
)}
|
||||
{hasThoughts && (
|
||||
<StatRow
|
||||
title="Thoughts"
|
||||
title={t('Thoughts')}
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
|
||||
/>
|
||||
)}
|
||||
{hasTool && (
|
||||
<StatRow
|
||||
title="Tool"
|
||||
title={t('Tool')}
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
|
||||
/>
|
||||
)}
|
||||
<StatRow
|
||||
title="Output"
|
||||
title={t('Output')}
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface OpenAIKeyPromptProps {
|
||||
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
|
||||
@@ -64,9 +65,11 @@ export function OpenAIKeyPrompt({
|
||||
const errorMessage = error.errors
|
||||
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
setValidationError(`Invalid credentials: ${errorMessage}`);
|
||||
setValidationError(
|
||||
t('Invalid credentials: {{errorMessage}}', { errorMessage }),
|
||||
);
|
||||
} else {
|
||||
setValidationError('Failed to validate credentials');
|
||||
setValidationError(t('Failed to validate credentials'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -205,7 +208,7 @@ export function OpenAIKeyPrompt({
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
OpenAI Configuration Required
|
||||
{t('OpenAI Configuration Required')}
|
||||
</Text>
|
||||
{validationError && (
|
||||
<Box marginTop={1}>
|
||||
@@ -214,7 +217,9 @@ export function OpenAIKeyPrompt({
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<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}>
|
||||
https://bailian.console.aliyun.com/?tab=model#/api-key
|
||||
</Text>
|
||||
@@ -225,7 +230,7 @@ export function OpenAIKeyPrompt({
|
||||
<Text
|
||||
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
API Key:
|
||||
{t('API Key:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
@@ -240,7 +245,7 @@ export function OpenAIKeyPrompt({
|
||||
<Text
|
||||
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
Base URL:
|
||||
{t('Base URL:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
@@ -255,7 +260,7 @@ export function OpenAIKeyPrompt({
|
||||
<Text
|
||||
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
Model:
|
||||
{t('Model:')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
@@ -267,7 +272,7 @@ export function OpenAIKeyPrompt({
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface ProQuotaDialogProps {
|
||||
failedModel: string;
|
||||
@@ -22,12 +23,12 @@ export function ProQuotaDialog({
|
||||
}: ProQuotaDialogProps): React.JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: 'Change auth (executes the /auth command)',
|
||||
label: t('Change auth (executes the /auth command)'),
|
||||
value: 'auth' as const,
|
||||
key: 'auth',
|
||||
},
|
||||
{
|
||||
label: `Continue with ${fallbackModel}`,
|
||||
label: t('Continue with {{model}}', { model: fallbackModel }),
|
||||
value: 'continue' as const,
|
||||
key: 'continue',
|
||||
},
|
||||
@@ -40,7 +41,7 @@ export function ProQuotaDialog({
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Text bold color={theme.status.warning}>
|
||||
Pro quota limit reached for {failedModel}.
|
||||
{t('Pro quota limit reached for {{model}}.', { model: failedModel })}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export enum QuitChoice {
|
||||
CANCEL = 'cancel',
|
||||
@@ -39,22 +40,22 @@ export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
|
||||
const options: Array<RadioSelectItem<QuitChoice>> = [
|
||||
{
|
||||
key: 'quit',
|
||||
label: 'Quit immediately (/quit)',
|
||||
label: t('Quit immediately (/quit)'),
|
||||
value: QuitChoice.QUIT,
|
||||
},
|
||||
{
|
||||
key: 'summary-and-quit',
|
||||
label: 'Generate summary and quit (/summary)',
|
||||
label: t('Generate summary and quit (/summary)'),
|
||||
value: QuitChoice.SUMMARY_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,
|
||||
},
|
||||
{
|
||||
key: 'cancel',
|
||||
label: 'Cancel (stay in application)',
|
||||
label: t('Cancel (stay in application)'),
|
||||
value: QuitChoice.CANCEL,
|
||||
},
|
||||
];
|
||||
@@ -69,7 +70,7 @@ export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
|
||||
marginLeft={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>
|
||||
|
||||
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
|
||||
|
||||
@@ -13,6 +13,7 @@ import qrcode from 'qrcode-terminal';
|
||||
import { Colors } from '../colors.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface QwenOAuthProgressProps {
|
||||
onTimeout: () => void;
|
||||
@@ -52,11 +53,11 @@ function QrCodeDisplay({
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
Qwen OAuth Authentication
|
||||
{t('Qwen OAuth Authentication')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Please visit this URL to authorize:</Text>
|
||||
<Text>{t('Please visit this URL to authorize:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={verificationUrl} fallback={false}>
|
||||
@@ -66,7 +67,7 @@ function QrCodeDisplay({
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>Or scan the QR code below:</Text>
|
||||
<Text>{t('Or scan the QR code below:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
@@ -103,15 +104,18 @@ function StatusDisplay({
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for authorization{dots}
|
||||
<Spinner type="dots" /> {t('Waiting for authorization')}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<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 color={Colors.AccentPurple}>(Press ESC or CTRL+C to cancel)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -215,19 +219,24 @@ export function QwenOAuthProgress({
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
Qwen OAuth Authentication Timeout
|
||||
{t('Qwen OAuth Authentication Timeout')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{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>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Press any key to return to authentication type selection.
|
||||
{t('Press any key to return to authentication type selection.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -275,16 +284,17 @@ export function QwenOAuthProgress({
|
||||
>
|
||||
<Box>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for Qwen OAuth authentication...
|
||||
<Spinner type="dots" />
|
||||
{t('Waiting for Qwen OAuth authentication...')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
Time remaining: {Math.floor(timeRemaining / 60)}:
|
||||
{t('Time remaining:')} {Math.floor(timeRemaining / 60)}:
|
||||
{(timeRemaining % 60).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
(Press ESC or CTRL+C to cancel)
|
||||
{t('(Press ESC or CTRL+C to cancel)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
duration: string;
|
||||
@@ -14,5 +15,8 @@ interface SessionSummaryDisplayProps {
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
}) => (
|
||||
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
|
||||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={duration}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1271,7 +1271,6 @@ describe('SettingsDialog', () => {
|
||||
vimMode: true,
|
||||
disableAutoUpdate: true,
|
||||
debugKeystrokeLogging: true,
|
||||
enablePromptCompletion: true,
|
||||
},
|
||||
ui: {
|
||||
hideWindowTitle: true,
|
||||
@@ -1517,7 +1516,6 @@ describe('SettingsDialog', () => {
|
||||
vimMode: false,
|
||||
disableAutoUpdate: false,
|
||||
debugKeystrokeLogging: false,
|
||||
enablePromptCompletion: false,
|
||||
},
|
||||
ui: {
|
||||
hideWindowTitle: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user