mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Headless enhancement: add stream-json as input-format/output-format to support programmatically use (#926)
This commit is contained in:
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
@@ -73,7 +73,16 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Launch CLI Non-Interactive",
|
"name": "Launch CLI Non-Interactive",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"],
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"start",
|
||||||
|
"--",
|
||||||
|
"-p",
|
||||||
|
"${input:prompt}",
|
||||||
|
"-y",
|
||||||
|
"--output-format",
|
||||||
|
"stream-json"
|
||||||
|
],
|
||||||
"skipFiles": ["<node_internals>/**"],
|
"skipFiles": ["<node_internals>/**"],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
|
|||||||
@@ -548,12 +548,25 @@ Arguments passed directly when running the CLI can override other configurations
|
|||||||
- The prompt is processed within the interactive session, not before it.
|
- The prompt is processed within the interactive session, not before it.
|
||||||
- Cannot be used when piping input from stdin.
|
- Cannot be used when piping input from stdin.
|
||||||
- Example: `qwen -i "explain this code"`
|
- Example: `qwen -i "explain this code"`
|
||||||
- **`--output-format <format>`**:
|
- **`--output-format <format>`** (**`-o <format>`**):
|
||||||
- **Description:** Specifies the format of the CLI output for non-interactive mode.
|
- **Description:** Specifies the format of the CLI output for non-interactive mode.
|
||||||
- **Values:**
|
- **Values:**
|
||||||
- `text`: (Default) The standard human-readable output.
|
- `text`: (Default) The standard human-readable output.
|
||||||
- `json`: A machine-readable JSON output.
|
- `json`: A machine-readable JSON output emitted at the end of execution.
|
||||||
- **Note:** For structured output and scripting, use the `--output-format json` flag.
|
- `stream-json`: Streaming JSON messages emitted as they occur during execution.
|
||||||
|
- **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless.md) for detailed information.
|
||||||
|
- **`--input-format <format>`**:
|
||||||
|
- **Description:** Specifies the format consumed from standard input.
|
||||||
|
- **Values:**
|
||||||
|
- `text`: (Default) Standard text input from stdin or command-line arguments.
|
||||||
|
- `stream-json`: JSON message protocol via stdin for bidirectional communication.
|
||||||
|
- **Requirement:** `--input-format stream-json` requires `--output-format stream-json` to be set.
|
||||||
|
- **Note:** When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless.md) for detailed information.
|
||||||
|
- **`--include-partial-messages`**:
|
||||||
|
- **Description:** Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming.
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Requirement:** Requires `--output-format stream-json` to be set.
|
||||||
|
- **Note:** See [Headless Mode](../features/headless.md) for detailed information about stream events.
|
||||||
- **`--sandbox`** (**`-s`**):
|
- **`--sandbox`** (**`-s`**):
|
||||||
- Enables sandbox mode for this session.
|
- Enables sandbox mode for this session.
|
||||||
- **`--sandbox-image`**:
|
- **`--sandbox-image`**:
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
|||||||
- [Output Formats](#output-formats)
|
- [Output Formats](#output-formats)
|
||||||
- [Text Output (Default)](#text-output-default)
|
- [Text Output (Default)](#text-output-default)
|
||||||
- [JSON Output](#json-output)
|
- [JSON Output](#json-output)
|
||||||
- [Response Schema](#response-schema)
|
|
||||||
- [Example Usage](#example-usage)
|
- [Example Usage](#example-usage)
|
||||||
|
- [Stream-JSON Output](#stream-json-output)
|
||||||
|
- [Input Format](#input-format)
|
||||||
- [File Redirection](#file-redirection)
|
- [File Redirection](#file-redirection)
|
||||||
- [Configuration Options](#configuration-options)
|
- [Configuration Options](#configuration-options)
|
||||||
- [Examples](#examples)
|
- [Examples](#examples)
|
||||||
@@ -22,7 +23,7 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
|||||||
- [Generate commit messages](#generate-commit-messages)
|
- [Generate commit messages](#generate-commit-messages)
|
||||||
- [API documentation](#api-documentation)
|
- [API documentation](#api-documentation)
|
||||||
- [Batch code analysis](#batch-code-analysis)
|
- [Batch code analysis](#batch-code-analysis)
|
||||||
- [Code review](#code-review-1)
|
- [PR code review](#pr-code-review)
|
||||||
- [Log analysis](#log-analysis)
|
- [Log analysis](#log-analysis)
|
||||||
- [Release notes generation](#release-notes-generation)
|
- [Release notes generation](#release-notes-generation)
|
||||||
- [Model and tool usage tracking](#model-and-tool-usage-tracking)
|
- [Model and tool usage tracking](#model-and-tool-usage-tracking)
|
||||||
@@ -66,6 +67,8 @@ cat README.md | qwen --prompt "Summarize this documentation"
|
|||||||
|
|
||||||
## Output Formats
|
## Output Formats
|
||||||
|
|
||||||
|
Qwen Code supports multiple output formats for different use cases:
|
||||||
|
|
||||||
### Text Output (Default)
|
### Text Output (Default)
|
||||||
|
|
||||||
Standard human-readable output:
|
Standard human-readable output:
|
||||||
@@ -82,56 +85,9 @@ The capital of France is Paris.
|
|||||||
|
|
||||||
### JSON Output
|
### JSON Output
|
||||||
|
|
||||||
Returns structured data including response, statistics, and metadata. This
|
Returns structured data as a JSON array. All messages are buffered and output together when the session completes. This format is ideal for programmatic processing and automation scripts.
|
||||||
format is ideal for programmatic processing and automation scripts.
|
|
||||||
|
|
||||||
#### Response Schema
|
The JSON output is an array of message objects. The output includes multiple message types: system messages (session initialization), assistant messages (AI responses), and result messages (execution summary).
|
||||||
|
|
||||||
The JSON output follows this high-level structure:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"response": "string", // The main AI-generated content answering your prompt
|
|
||||||
"stats": {
|
|
||||||
// Usage metrics and performance data
|
|
||||||
"models": {
|
|
||||||
// Per-model API and token usage statistics
|
|
||||||
"[model-name]": {
|
|
||||||
"api": {
|
|
||||||
/* request counts, errors, latency */
|
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
/* prompt, response, cached, total counts */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tools": {
|
|
||||||
// Tool execution statistics
|
|
||||||
"totalCalls": "number",
|
|
||||||
"totalSuccess": "number",
|
|
||||||
"totalFail": "number",
|
|
||||||
"totalDurationMs": "number",
|
|
||||||
"totalDecisions": {
|
|
||||||
/* accept, reject, modify, auto_accept counts */
|
|
||||||
},
|
|
||||||
"byName": {
|
|
||||||
/* per-tool detailed stats */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
// File modification statistics
|
|
||||||
"totalLinesAdded": "number",
|
|
||||||
"totalLinesRemoved": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
// Present only when an error occurred
|
|
||||||
"type": "string", // Error type (e.g., "ApiError", "AuthError")
|
|
||||||
"message": "string", // Human-readable error description
|
|
||||||
"code": "number" // Optional error code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Example Usage
|
#### Example Usage
|
||||||
|
|
||||||
@@ -139,63 +95,81 @@ The JSON output follows this high-level structure:
|
|||||||
qwen -p "What is the capital of France?" --output-format json
|
qwen -p "What is the capital of France?" --output-format json
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Output (at end of execution):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
[
|
||||||
"response": "The capital of France is Paris.",
|
{
|
||||||
"stats": {
|
"type": "system",
|
||||||
"models": {
|
"subtype": "session_start",
|
||||||
"qwen3-coder-plus": {
|
"uuid": "...",
|
||||||
"api": {
|
"session_id": "...",
|
||||||
"totalRequests": 2,
|
"model": "qwen3-coder-plus",
|
||||||
"totalErrors": 0,
|
...
|
||||||
"totalLatencyMs": 5053
|
},
|
||||||
},
|
{
|
||||||
"tokens": {
|
"type": "assistant",
|
||||||
"prompt": 24939,
|
"uuid": "...",
|
||||||
"candidates": 20,
|
"session_id": "...",
|
||||||
"total": 25113,
|
"message": {
|
||||||
"cached": 21263,
|
"id": "...",
|
||||||
"thoughts": 154,
|
"type": "message",
|
||||||
"tool": 0
|
"role": "assistant",
|
||||||
|
"model": "qwen3-coder-plus",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "The capital of France is Paris."
|
||||||
}
|
}
|
||||||
}
|
],
|
||||||
|
"usage": {...}
|
||||||
},
|
},
|
||||||
"tools": {
|
"parent_tool_use_id": null
|
||||||
"totalCalls": 1,
|
},
|
||||||
"totalSuccess": 1,
|
{
|
||||||
"totalFail": 0,
|
"type": "result",
|
||||||
"totalDurationMs": 1881,
|
"subtype": "success",
|
||||||
"totalDecisions": {
|
"uuid": "...",
|
||||||
"accept": 0,
|
"session_id": "...",
|
||||||
"reject": 0,
|
"is_error": false,
|
||||||
"modify": 0,
|
"duration_ms": 1234,
|
||||||
"auto_accept": 1
|
"result": "The capital of France is Paris.",
|
||||||
},
|
"usage": {...}
|
||||||
"byName": {
|
|
||||||
"google_web_search": {
|
|
||||||
"count": 1,
|
|
||||||
"success": 1,
|
|
||||||
"fail": 0,
|
|
||||||
"durationMs": 1881,
|
|
||||||
"decisions": {
|
|
||||||
"accept": 0,
|
|
||||||
"reject": 0,
|
|
||||||
"modify": 0,
|
|
||||||
"auto_accept": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"totalLinesAdded": 0,
|
|
||||||
"totalLinesRemoved": 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Stream-JSON Output
|
||||||
|
|
||||||
|
Stream-JSON format emits JSON messages immediately as they occur during execution, enabling real-time monitoring. This format uses line-delimited JSON where each message is a complete JSON object on a single line.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qwen -p "Explain TypeScript" --output-format stream-json
|
||||||
|
```
|
||||||
|
|
||||||
|
Output (streaming as events occur):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type":"system","subtype":"session_start","uuid":"...","session_id":"..."}
|
||||||
|
{"type":"assistant","uuid":"...","session_id":"...","message":{...}}
|
||||||
|
{"type":"result","subtype":"success","uuid":"...","session_id":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
When combined with `--include-partial-messages`, additional stream events are emitted in real-time (message_start, content_block_delta, etc.) for real-time UI updates.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qwen -p "Write a Python script" --output-format stream-json --include-partial-messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Format
|
||||||
|
|
||||||
|
The `--input-format` parameter controls how Qwen Code consumes input from standard input:
|
||||||
|
|
||||||
|
- **`text`** (default): Standard text input from stdin or command-line arguments
|
||||||
|
- **`stream-json`**: JSON message protocol via stdin for bidirectional communication
|
||||||
|
|
||||||
|
> **Note:** Stream-json input mode is currently under construction and is intended for SDK integration. It requires `--output-format stream-json` to be set.
|
||||||
|
|
||||||
### File Redirection
|
### File Redirection
|
||||||
|
|
||||||
Save output to files or pipe to other commands:
|
Save output to files or pipe to other commands:
|
||||||
@@ -212,48 +186,53 @@ qwen -p "Add more details" >> docker-explanation.txt
|
|||||||
qwen -p "What is Kubernetes?" --output-format json | jq '.response'
|
qwen -p "What is Kubernetes?" --output-format json | jq '.response'
|
||||||
qwen -p "Explain microservices" | wc -w
|
qwen -p "Explain microservices" | wc -w
|
||||||
qwen -p "List programming languages" | grep -i "python"
|
qwen -p "List programming languages" | grep -i "python"
|
||||||
|
|
||||||
|
# Stream-JSON output for real-time processing
|
||||||
|
qwen -p "Explain Docker" --output-format stream-json | jq '.type'
|
||||||
|
qwen -p "Write code" --output-format stream-json --include-partial-messages | jq '.event.type'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Options
|
## Configuration Options
|
||||||
|
|
||||||
Key command-line options for headless usage:
|
Key command-line options for headless usage:
|
||||||
|
|
||||||
| Option | Description | Example |
|
| Option | Description | Example |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------ |
|
| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||||
| `--output-format` | Specify output format (text, json) | `qwen -p "query" --output-format json` |
|
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||||
| `--model`, `-m` | Specify the Qwen model | `qwen -p "query" -m qwen3-coder-plus` |
|
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||||
|
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||||
|
|
||||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md).
|
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md).
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
#### Code review
|
### Code review
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt
|
cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Generate commit messages
|
### Generate commit messages
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json)
|
result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json)
|
||||||
echo "$result" | jq -r '.response'
|
echo "$result" | jq -r '.response'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### API documentation
|
### API documentation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json)
|
result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json)
|
||||||
echo "$result" | jq -r '.response' > openapi.json
|
echo "$result" | jq -r '.response' > openapi.json
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Batch code analysis
|
### Batch code analysis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
for file in src/*.py; do
|
for file in src/*.py; do
|
||||||
@@ -264,20 +243,20 @@ for file in src/*.py; do
|
|||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Code review
|
### PR code review
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json)
|
result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json)
|
||||||
echo "$result" | jq -r '.response' > pr-review.json
|
echo "$result" | jq -r '.response' > pr-review.json
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Log analysis
|
### Log analysis
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt
|
grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Release notes generation
|
### Release notes generation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json)
|
result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json)
|
||||||
@@ -286,7 +265,7 @@ echo "$response"
|
|||||||
echo "$response" >> CHANGELOG.md
|
echo "$response" >> CHANGELOG.md
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Model and tool usage tracking
|
### Model and tool usage tracking
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
result=$(qwen -p "Explain this database schema" --include-directories db --output-format json)
|
result=$(qwen -p "Explain this database schema" --include-directories db --output-format json)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe('JSON output', () => {
|
|||||||
await rig.cleanup();
|
await rig.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a valid JSON with response and stats', async () => {
|
it('should return a valid JSON array with result message containing response and stats', async () => {
|
||||||
const result = await rig.run(
|
const result = await rig.run(
|
||||||
'What is the capital of France?',
|
'What is the capital of France?',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
@@ -27,12 +27,30 @@ describe('JSON output', () => {
|
|||||||
);
|
);
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
|
|
||||||
expect(parsed).toHaveProperty('response');
|
// The output should be an array of messages
|
||||||
expect(typeof parsed.response).toBe('string');
|
expect(Array.isArray(parsed)).toBe(true);
|
||||||
expect(parsed.response.toLowerCase()).toContain('paris');
|
expect(parsed.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
expect(parsed).toHaveProperty('stats');
|
// Find the result message (should be the last message)
|
||||||
expect(typeof parsed.stats).toBe('object');
|
const resultMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultMessage).toBeDefined();
|
||||||
|
expect(resultMessage).toHaveProperty('is_error');
|
||||||
|
expect(resultMessage.is_error).toBe(false);
|
||||||
|
expect(resultMessage).toHaveProperty('result');
|
||||||
|
expect(typeof resultMessage.result).toBe('string');
|
||||||
|
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||||
|
|
||||||
|
// Stats may be present if available
|
||||||
|
if ('stats' in resultMessage) {
|
||||||
|
expect(typeof resultMessage.stats).toBe('object');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a JSON error for enforced auth mismatch before running', async () => {
|
it('should return a JSON error for enforced auth mismatch before running', async () => {
|
||||||
@@ -56,32 +74,236 @@ describe('JSON output', () => {
|
|||||||
expect(thrown).toBeDefined();
|
expect(thrown).toBeDefined();
|
||||||
const message = (thrown as Error).message;
|
const message = (thrown as Error).message;
|
||||||
|
|
||||||
// Use a regex to find the first complete JSON object in the string
|
// The error JSON is written to stdout as a CLIResultMessageError
|
||||||
const jsonMatch = message.match(/{[\s\S]*}/);
|
// Extract stdout from the error message
|
||||||
|
const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/);
|
||||||
// Fail if no JSON-like text was found
|
|
||||||
expect(
|
expect(
|
||||||
jsonMatch,
|
stdoutMatch,
|
||||||
'Expected to find a JSON object in the error output',
|
'Expected to find stdout in the error message',
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
|
|
||||||
let payload;
|
const stdout = stdoutMatch![1];
|
||||||
|
let parsed: unknown[];
|
||||||
try {
|
try {
|
||||||
// Parse the matched JSON string
|
// Parse the JSON array from stdout
|
||||||
payload = JSON.parse(jsonMatch![0]);
|
parsed = JSON.parse(stdout);
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('Failed to parse the following JSON:', jsonMatch![0]);
|
console.error('Failed to parse the following JSON:', stdout);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Test failed: Could not parse JSON from error message. Details: ${parseError}`,
|
`Test failed: Could not parse JSON from stdout. Details: ${parseError}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(payload.error).toBeDefined();
|
// The output should be an array of messages
|
||||||
expect(payload.error.type).toBe('Error');
|
expect(Array.isArray(parsed)).toBe(true);
|
||||||
expect(payload.error.code).toBe(1);
|
expect(parsed.length).toBeGreaterThan(0);
|
||||||
expect(payload.error.message).toContain(
|
|
||||||
|
// Find the result message with error
|
||||||
|
const resultMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'result' &&
|
||||||
|
'is_error' in msg &&
|
||||||
|
msg.is_error === true,
|
||||||
|
) as {
|
||||||
|
type: string;
|
||||||
|
is_error: boolean;
|
||||||
|
subtype: string;
|
||||||
|
error?: { message: string; type?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resultMessage).toBeDefined();
|
||||||
|
expect(resultMessage.is_error).toBe(true);
|
||||||
|
expect(resultMessage).toHaveProperty('subtype');
|
||||||
|
expect(resultMessage.subtype).toBe('error_during_execution');
|
||||||
|
expect(resultMessage).toHaveProperty('error');
|
||||||
|
expect(resultMessage.error).toBeDefined();
|
||||||
|
expect(resultMessage.error?.message).toContain(
|
||||||
'configured auth type is qwen-oauth',
|
'configured auth type is qwen-oauth',
|
||||||
);
|
);
|
||||||
expect(payload.error.message).toContain('current auth type is openai');
|
expect(resultMessage.error?.message).toContain(
|
||||||
|
'current auth type is openai',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return line-delimited JSON messages for stream-json output format', async () => {
|
||||||
|
const result = await rig.run(
|
||||||
|
'What is the capital of France?',
|
||||||
|
'--output-format',
|
||||||
|
'stream-json',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream-json output is line-delimited JSON (one JSON object per line)
|
||||||
|
const lines = result
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim());
|
||||||
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Parse each line as a JSON object
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
messages.push(parsed);
|
||||||
|
} catch (parseError) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have at least system, assistant, and result messages
|
||||||
|
expect(messages.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|
||||||
|
// Find system message
|
||||||
|
const systemMessage = messages.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'system',
|
||||||
|
);
|
||||||
|
expect(systemMessage).toBeDefined();
|
||||||
|
expect(systemMessage).toHaveProperty('subtype');
|
||||||
|
expect(systemMessage).toHaveProperty('session_id');
|
||||||
|
|
||||||
|
// Find assistant message
|
||||||
|
const assistantMessage = messages.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'assistant',
|
||||||
|
);
|
||||||
|
expect(assistantMessage).toBeDefined();
|
||||||
|
expect(assistantMessage).toHaveProperty('message');
|
||||||
|
expect(assistantMessage).toHaveProperty('session_id');
|
||||||
|
|
||||||
|
// Find result message (should be the last message)
|
||||||
|
const resultMessage = messages[messages.length - 1] as {
|
||||||
|
type: string;
|
||||||
|
is_error: boolean;
|
||||||
|
result: string;
|
||||||
|
};
|
||||||
|
expect(resultMessage).toBeDefined();
|
||||||
|
expect(
|
||||||
|
typeof resultMessage === 'object' &&
|
||||||
|
resultMessage !== null &&
|
||||||
|
'type' in resultMessage &&
|
||||||
|
resultMessage.type === 'result',
|
||||||
|
).toBe(true);
|
||||||
|
expect(resultMessage).toHaveProperty('is_error');
|
||||||
|
expect(resultMessage.is_error).toBe(false);
|
||||||
|
expect(resultMessage).toHaveProperty('result');
|
||||||
|
expect(typeof resultMessage.result).toBe('string');
|
||||||
|
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include stream events when using stream-json with include-partial-messages', async () => {
|
||||||
|
const result = await rig.run(
|
||||||
|
'What is the capital of France?',
|
||||||
|
'--output-format',
|
||||||
|
'stream-json',
|
||||||
|
'--include-partial-messages',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream-json output is line-delimited JSON (one JSON object per line)
|
||||||
|
const lines = result
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim());
|
||||||
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Parse each line as a JSON object
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
messages.push(parsed);
|
||||||
|
} catch (parseError) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have more messages than without include-partial-messages
|
||||||
|
// because we're including stream events
|
||||||
|
expect(messages.length).toBeGreaterThan(3);
|
||||||
|
|
||||||
|
// Find stream_event messages
|
||||||
|
const streamEvents = messages.filter(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'stream_event',
|
||||||
|
);
|
||||||
|
expect(streamEvents.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify stream event structure
|
||||||
|
const firstStreamEvent = streamEvents[0];
|
||||||
|
expect(firstStreamEvent).toHaveProperty('event');
|
||||||
|
expect(firstStreamEvent).toHaveProperty('session_id');
|
||||||
|
expect(firstStreamEvent).toHaveProperty('uuid');
|
||||||
|
|
||||||
|
// Check for expected stream event types
|
||||||
|
const eventTypes = streamEvents.map((event: unknown) =>
|
||||||
|
typeof event === 'object' &&
|
||||||
|
event !== null &&
|
||||||
|
'event' in event &&
|
||||||
|
typeof event.event === 'object' &&
|
||||||
|
event.event !== null &&
|
||||||
|
'type' in event.event
|
||||||
|
? event.event.type
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have message_start event
|
||||||
|
expect(eventTypes).toContain('message_start');
|
||||||
|
|
||||||
|
// Should have content_block_start event
|
||||||
|
expect(eventTypes).toContain('content_block_start');
|
||||||
|
|
||||||
|
// Should have content_block_delta events
|
||||||
|
expect(eventTypes).toContain('content_block_delta');
|
||||||
|
|
||||||
|
// Should have content_block_stop event
|
||||||
|
expect(eventTypes).toContain('content_block_stop');
|
||||||
|
|
||||||
|
// Should have message_stop event
|
||||||
|
expect(eventTypes).toContain('message_stop');
|
||||||
|
|
||||||
|
// Verify that we still have the complete assistant message
|
||||||
|
const assistantMessage = messages.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'assistant',
|
||||||
|
);
|
||||||
|
expect(assistantMessage).toBeDefined();
|
||||||
|
expect(assistantMessage).toHaveProperty('message');
|
||||||
|
|
||||||
|
// Verify that we still have the result message
|
||||||
|
const resultMessage = messages[messages.length - 1] as {
|
||||||
|
type: string;
|
||||||
|
is_error: boolean;
|
||||||
|
result: string;
|
||||||
|
};
|
||||||
|
expect(resultMessage).toBeDefined();
|
||||||
|
expect(
|
||||||
|
typeof resultMessage === 'object' &&
|
||||||
|
resultMessage !== null &&
|
||||||
|
'type' in resultMessage &&
|
||||||
|
resultMessage.type === 'result',
|
||||||
|
).toBe(true);
|
||||||
|
expect(resultMessage).toHaveProperty('is_error');
|
||||||
|
expect(resultMessage.is_error).toBe(false);
|
||||||
|
expect(resultMessage).toHaveProperty('result');
|
||||||
|
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -340,7 +340,8 @@ export class TestRig {
|
|||||||
// as it would corrupt the JSON
|
// as it would corrupt the JSON
|
||||||
const isJsonOutput =
|
const isJsonOutput =
|
||||||
commandArgs.includes('--output-format') &&
|
commandArgs.includes('--output-format') &&
|
||||||
commandArgs.includes('json');
|
(commandArgs.includes('json') ||
|
||||||
|
commandArgs.includes('stream-json'));
|
||||||
|
|
||||||
// If we have stderr output and it's not a JSON test, include that also
|
// If we have stderr output and it's not a JSON test, include that also
|
||||||
if (stderr && !isJsonOutput) {
|
if (stderr && !isJsonOutput) {
|
||||||
@@ -349,7 +350,23 @@ export class TestRig {
|
|||||||
|
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
// Check if this is a JSON output test - for JSON errors, the error is in stdout
|
||||||
|
const isJsonOutputOnError =
|
||||||
|
commandArgs.includes('--output-format') &&
|
||||||
|
(commandArgs.includes('json') ||
|
||||||
|
commandArgs.includes('stream-json'));
|
||||||
|
|
||||||
|
// For JSON output tests, include stdout in the error message
|
||||||
|
// as the error JSON is written to stdout
|
||||||
|
if (isJsonOutputOnError && stdout) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Process exited with code ${code}:\nStdout:\n${stdout}\n\nStderr:\n${stderr}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,16 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"qwen": "dist/index.js"
|
"qwen": "dist/index.js"
|
||||||
},
|
},
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node ../../scripts/build_package.js",
|
"build": "node ../../scripts/build_package.js",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
|||||||
@@ -392,6 +392,49 @@ describe('parseArguments', () => {
|
|||||||
mockConsoleError.mockRestore();
|
mockConsoleError.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw an error when include-partial-messages is used without stream-json output', async () => {
|
||||||
|
process.argv = ['node', 'script.js', '--include-partial-messages'];
|
||||||
|
|
||||||
|
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||||
|
throw new Error('process.exit called');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockConsoleError = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||||
|
'process.exit called',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'--include-partial-messages requires --output-format stream-json',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockExit.mockRestore();
|
||||||
|
mockConsoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse stream-json formats and include-partial-messages flag', async () => {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'script.js',
|
||||||
|
'--output-format',
|
||||||
|
'stream-json',
|
||||||
|
'--input-format',
|
||||||
|
'stream-json',
|
||||||
|
'--include-partial-messages',
|
||||||
|
];
|
||||||
|
|
||||||
|
const argv = await parseArguments({} as Settings);
|
||||||
|
|
||||||
|
expect(argv.outputFormat).toBe('stream-json');
|
||||||
|
expect(argv.inputFormat).toBe('stream-json');
|
||||||
|
expect(argv.includePartialMessages).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should allow --approval-mode without --yolo', async () => {
|
it('should allow --approval-mode without --yolo', async () => {
|
||||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
||||||
const argv = await parseArguments({} as Settings);
|
const argv = await parseArguments({} as Settings);
|
||||||
@@ -473,6 +516,34 @@ describe('loadCliConfig', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should propagate stream-json formats to config', async () => {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'script.js',
|
||||||
|
'--output-format',
|
||||||
|
'stream-json',
|
||||||
|
'--input-format',
|
||||||
|
'stream-json',
|
||||||
|
'--include-partial-messages',
|
||||||
|
];
|
||||||
|
const argv = await parseArguments({} as Settings);
|
||||||
|
const settings: Settings = {};
|
||||||
|
const config = await loadCliConfig(
|
||||||
|
settings,
|
||||||
|
[],
|
||||||
|
new ExtensionEnablementManager(
|
||||||
|
ExtensionStorage.getUserExtensionsDir(),
|
||||||
|
argv.extensions,
|
||||||
|
),
|
||||||
|
'test-session',
|
||||||
|
argv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.getOutputFormat()).toBe('stream-json');
|
||||||
|
expect(config.getInputFormat()).toBe('stream-json');
|
||||||
|
expect(config.getIncludePartialMessages()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
|
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
|
||||||
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
||||||
const argv = await parseArguments({} as Settings);
|
const argv = await parseArguments({} as Settings);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import type {
|
import type {
|
||||||
FileFilteringOptions,
|
FileFilteringOptions,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
OutputFormat,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { extensionsCommand } from '../commands/extensions.js';
|
import { extensionsCommand } from '../commands/extensions.js';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +23,8 @@ import {
|
|||||||
WriteFileTool,
|
WriteFileTool,
|
||||||
resolveTelemetrySettings,
|
resolveTelemetrySettings,
|
||||||
FatalConfigError,
|
FatalConfigError,
|
||||||
|
InputFormat,
|
||||||
|
OutputFormat,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { Settings } from './settings.js';
|
import type { Settings } from './settings.js';
|
||||||
import yargs, { type Argv } from 'yargs';
|
import yargs, { type Argv } from 'yargs';
|
||||||
@@ -124,7 +125,24 @@ export interface CliArgs {
|
|||||||
screenReader: boolean | undefined;
|
screenReader: boolean | undefined;
|
||||||
vlmSwitchMode: string | undefined;
|
vlmSwitchMode: string | undefined;
|
||||||
useSmartEdit: boolean | undefined;
|
useSmartEdit: boolean | undefined;
|
||||||
|
inputFormat?: string | undefined;
|
||||||
outputFormat: string | undefined;
|
outputFormat: string | undefined;
|
||||||
|
includePartialMessages?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutputFormat(
|
||||||
|
format: string | OutputFormat | undefined,
|
||||||
|
): OutputFormat | undefined {
|
||||||
|
if (!format) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (format === OutputFormat.STREAM_JSON) {
|
||||||
|
return OutputFormat.STREAM_JSON;
|
||||||
|
}
|
||||||
|
if (format === 'json' || format === OutputFormat.JSON) {
|
||||||
|
return OutputFormat.JSON;
|
||||||
|
}
|
||||||
|
return OutputFormat.TEXT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||||
@@ -359,11 +377,23 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.',
|
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.',
|
||||||
default: process.env['VLM_SWITCH_MODE'],
|
default: process.env['VLM_SWITCH_MODE'],
|
||||||
})
|
})
|
||||||
|
.option('input-format', {
|
||||||
|
type: 'string',
|
||||||
|
choices: ['text', 'stream-json'],
|
||||||
|
description: 'The format consumed from standard input.',
|
||||||
|
default: 'text',
|
||||||
|
})
|
||||||
.option('output-format', {
|
.option('output-format', {
|
||||||
alias: 'o',
|
alias: 'o',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The format of the CLI output.',
|
description: 'The format of the CLI output.',
|
||||||
choices: ['text', 'json'],
|
choices: ['text', 'json', 'stream-json'],
|
||||||
|
})
|
||||||
|
.option('include-partial-messages', {
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Include partial assistant messages when using stream-json output.',
|
||||||
|
default: false,
|
||||||
})
|
})
|
||||||
.deprecateOption(
|
.deprecateOption(
|
||||||
'show-memory-usage',
|
'show-memory-usage',
|
||||||
@@ -408,6 +438,18 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
|||||||
if (argv['yolo'] && argv['approvalMode']) {
|
if (argv['yolo'] && argv['approvalMode']) {
|
||||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
argv['includePartialMessages'] &&
|
||||||
|
argv['outputFormat'] !== OutputFormat.STREAM_JSON
|
||||||
|
) {
|
||||||
|
return '--include-partial-messages requires --output-format stream-json';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
argv['inputFormat'] === 'stream-json' &&
|
||||||
|
argv['outputFormat'] !== OutputFormat.STREAM_JSON
|
||||||
|
) {
|
||||||
|
return '--input-format stream-json requires --output-format stream-json';
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -588,6 +630,22 @@ export async function loadCliConfig(
|
|||||||
|
|
||||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||||
const question = argv.promptInteractive || argv.prompt || '';
|
const question = argv.promptInteractive || argv.prompt || '';
|
||||||
|
const inputFormat: InputFormat =
|
||||||
|
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
|
||||||
|
const argvOutputFormat = normalizeOutputFormat(
|
||||||
|
argv.outputFormat as string | OutputFormat | undefined,
|
||||||
|
);
|
||||||
|
const settingsOutputFormat = normalizeOutputFormat(settings.output?.format);
|
||||||
|
const outputFormat =
|
||||||
|
argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT;
|
||||||
|
const outputSettingsFormat: OutputFormat =
|
||||||
|
outputFormat === OutputFormat.STREAM_JSON
|
||||||
|
? settingsOutputFormat &&
|
||||||
|
settingsOutputFormat !== OutputFormat.STREAM_JSON
|
||||||
|
? settingsOutputFormat
|
||||||
|
: OutputFormat.TEXT
|
||||||
|
: (outputFormat as OutputFormat);
|
||||||
|
const includePartialMessages = Boolean(argv.includePartialMessages);
|
||||||
|
|
||||||
// Determine approval mode with backward compatibility
|
// Determine approval mode with backward compatibility
|
||||||
let approvalMode: ApprovalMode;
|
let approvalMode: ApprovalMode;
|
||||||
@@ -629,11 +687,31 @@ export async function loadCliConfig(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
// Interactive mode determination with priority:
|
||||||
|
// 1. If promptInteractive (-i flag) is provided, it is explicitly interactive
|
||||||
|
// 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive
|
||||||
|
// 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive
|
||||||
const hasQuery = !!argv.query;
|
const hasQuery = !!argv.query;
|
||||||
const interactive =
|
const hasPrompt = !!argv.prompt;
|
||||||
!!argv.promptInteractive ||
|
let interactive: boolean;
|
||||||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
|
if (argv.promptInteractive) {
|
||||||
|
// Priority 1: Explicit -i flag means interactive
|
||||||
|
interactive = true;
|
||||||
|
} else if (
|
||||||
|
(outputFormat === OutputFormat.STREAM_JSON ||
|
||||||
|
outputFormat === OutputFormat.JSON) &&
|
||||||
|
(hasQuery || hasPrompt)
|
||||||
|
) {
|
||||||
|
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
|
||||||
|
interactive = false;
|
||||||
|
} else if (!hasQuery && !hasPrompt) {
|
||||||
|
// Priority 3: No query or prompt means interactive only if TTY (format arguments ignored)
|
||||||
|
interactive = process.stdin.isTTY ?? false;
|
||||||
|
} else {
|
||||||
|
// Default: If we have query/prompt but output format is TEXT, assume non-interactive
|
||||||
|
// (fallback for edge cases where query/prompt is provided with TEXT output)
|
||||||
|
interactive = false;
|
||||||
|
}
|
||||||
// In non-interactive mode, exclude tools that require a prompt.
|
// In non-interactive mode, exclude tools that require a prompt.
|
||||||
const extraExcludes: string[] = [];
|
const extraExcludes: string[] = [];
|
||||||
if (!interactive && !argv.experimentalAcp) {
|
if (!interactive && !argv.experimentalAcp) {
|
||||||
@@ -755,6 +833,9 @@ export async function loadCliConfig(
|
|||||||
blockedMcpServers,
|
blockedMcpServers,
|
||||||
noBrowser: !!process.env['NO_BROWSER'],
|
noBrowser: !!process.env['NO_BROWSER'],
|
||||||
authType: settings.security?.auth?.selectedType,
|
authType: settings.security?.auth?.selectedType,
|
||||||
|
inputFormat,
|
||||||
|
outputFormat,
|
||||||
|
includePartialMessages,
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
...(settings.model?.generationConfig || {}),
|
...(settings.model?.generationConfig || {}),
|
||||||
model: resolvedModel,
|
model: resolvedModel,
|
||||||
@@ -798,7 +879,7 @@ export async function loadCliConfig(
|
|||||||
eventEmitter: appEvents,
|
eventEmitter: appEvents,
|
||||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||||
output: {
|
output: {
|
||||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
format: outputSettingsFormat,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,6 +483,27 @@ export class LoadedSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal LoadedSettings instance with empty settings.
|
||||||
|
* Used in stream-json mode where settings are ignored.
|
||||||
|
*/
|
||||||
|
export function createMinimalSettings(): LoadedSettings {
|
||||||
|
const emptySettingsFile: SettingsFile = {
|
||||||
|
path: '',
|
||||||
|
settings: {},
|
||||||
|
originalSettings: {},
|
||||||
|
rawJson: '{}',
|
||||||
|
};
|
||||||
|
return new LoadedSettings(
|
||||||
|
emptySettingsFile,
|
||||||
|
emptySettingsFile,
|
||||||
|
emptySettingsFile,
|
||||||
|
emptySettingsFile,
|
||||||
|
false,
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function findEnvFile(startDir: string): string | null {
|
function findEnvFile(startDir: string): string | null {
|
||||||
let currentDir = path.resolve(startDir);
|
let currentDir = path.resolve(startDir);
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import { type LoadedSettings } from './config/settings.js';
|
import { type LoadedSettings } from './config/settings.js';
|
||||||
import { appEvents, AppEvent } from './utils/events.js';
|
import { appEvents, AppEvent } from './utils/events.js';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import { OutputFormat } from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
// Custom error to identify mock process.exit calls
|
// Custom error to identify mock process.exit calls
|
||||||
class MockProcessExitError extends Error {
|
class MockProcessExitError extends Error {
|
||||||
@@ -158,6 +159,7 @@ describe('gemini.tsx main function', () => {
|
|||||||
getScreenReader: () => false,
|
getScreenReader: () => false,
|
||||||
getGeminiMdFileCount: () => 0,
|
getGeminiMdFileCount: () => 0,
|
||||||
getProjectRoot: () => '/',
|
getProjectRoot: () => '/',
|
||||||
|
getOutputFormat: () => OutputFormat.TEXT,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
});
|
});
|
||||||
vi.mocked(loadSettings).mockReturnValue({
|
vi.mocked(loadSettings).mockReturnValue({
|
||||||
@@ -230,6 +232,143 @@ describe('gemini.tsx main function', () => {
|
|||||||
// Avoid the process.exit error from being thrown.
|
// Avoid the process.exit error from being thrown.
|
||||||
processExitSpy.mockRestore();
|
processExitSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('invokes runNonInteractiveStreamJson and performs cleanup in stream-json mode', async () => {
|
||||||
|
const originalIsTTY = Object.getOwnPropertyDescriptor(
|
||||||
|
process.stdin,
|
||||||
|
'isTTY',
|
||||||
|
);
|
||||||
|
const originalIsRaw = Object.getOwnPropertyDescriptor(
|
||||||
|
process.stdin,
|
||||||
|
'isRaw',
|
||||||
|
);
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(process.stdin, 'isRaw', {
|
||||||
|
value: false,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processExitSpy = vi
|
||||||
|
.spyOn(process, 'exit')
|
||||||
|
.mockImplementation((code) => {
|
||||||
|
throw new MockProcessExitError(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loadCliConfig, parseArguments } = await import(
|
||||||
|
'./config/config.js'
|
||||||
|
);
|
||||||
|
const { loadSettings } = await import('./config/settings.js');
|
||||||
|
const cleanupModule = await import('./utils/cleanup.js');
|
||||||
|
const extensionModule = await import('./config/extension.js');
|
||||||
|
const validatorModule = await import('./validateNonInterActiveAuth.js');
|
||||||
|
const streamJsonModule = await import('./nonInteractive/session.js');
|
||||||
|
const initializerModule = await import('./core/initializer.js');
|
||||||
|
const startupWarningsModule = await import('./utils/startupWarnings.js');
|
||||||
|
const userStartupWarningsModule = await import(
|
||||||
|
'./utils/userStartupWarnings.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
|
||||||
|
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
|
||||||
|
runExitCleanupMock.mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]);
|
||||||
|
vi.spyOn(
|
||||||
|
extensionModule.ExtensionStorage,
|
||||||
|
'getUserExtensionsDir',
|
||||||
|
).mockReturnValue('/tmp/extensions');
|
||||||
|
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
|
||||||
|
authError: null,
|
||||||
|
themeError: null,
|
||||||
|
shouldOpenAuthDialog: false,
|
||||||
|
geminiMdFileCount: 0,
|
||||||
|
});
|
||||||
|
vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);
|
||||||
|
vi.spyOn(
|
||||||
|
userStartupWarningsModule,
|
||||||
|
'getUserStartupWarnings',
|
||||||
|
).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const validatedConfig = { validated: true } as unknown as Config;
|
||||||
|
const validateAuthSpy = vi
|
||||||
|
.spyOn(validatorModule, 'validateNonInteractiveAuth')
|
||||||
|
.mockResolvedValue(validatedConfig);
|
||||||
|
const runStreamJsonSpy = vi
|
||||||
|
.spyOn(streamJsonModule, 'runNonInteractiveStreamJson')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
vi.mocked(loadSettings).mockReturnValue({
|
||||||
|
errors: [],
|
||||||
|
merged: {
|
||||||
|
advanced: {},
|
||||||
|
security: { auth: {} },
|
||||||
|
ui: {},
|
||||||
|
},
|
||||||
|
setValue: vi.fn(),
|
||||||
|
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
vi.mocked(parseArguments).mockResolvedValue({
|
||||||
|
extensions: [],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const configStub = {
|
||||||
|
isInteractive: () => false,
|
||||||
|
getQuestion: () => ' hello stream ',
|
||||||
|
getSandbox: () => false,
|
||||||
|
getDebugMode: () => false,
|
||||||
|
getListExtensions: () => false,
|
||||||
|
getMcpServers: () => ({}),
|
||||||
|
initialize: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getIdeMode: () => false,
|
||||||
|
getExperimentalZedIntegration: () => false,
|
||||||
|
getScreenReader: () => false,
|
||||||
|
getGeminiMdFileCount: () => 0,
|
||||||
|
getProjectRoot: () => '/',
|
||||||
|
getInputFormat: () => 'stream-json',
|
||||||
|
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
|
||||||
|
|
||||||
|
process.env['SANDBOX'] = '1';
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof MockProcessExitError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processExitSpy.mockRestore();
|
||||||
|
if (originalIsTTY) {
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
|
||||||
|
} else {
|
||||||
|
delete (process.stdin as { isTTY?: unknown }).isTTY;
|
||||||
|
}
|
||||||
|
if (originalIsRaw) {
|
||||||
|
Object.defineProperty(process.stdin, 'isRaw', originalIsRaw);
|
||||||
|
} else {
|
||||||
|
delete (process.stdin as { isRaw?: unknown }).isRaw;
|
||||||
|
}
|
||||||
|
delete process.env['SANDBOX'];
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(runStreamJsonSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const [configArg, inputArg] = runStreamJsonSpy.mock.calls[0];
|
||||||
|
expect(configArg).toBe(validatedConfig);
|
||||||
|
expect(inputArg).toBe('hello stream');
|
||||||
|
|
||||||
|
expect(validateAuthSpy).toHaveBeenCalledWith(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
configStub,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('gemini.tsx main function kitty protocol', () => {
|
describe('gemini.tsx main function kitty protocol', () => {
|
||||||
@@ -337,7 +476,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
screenReader: undefined,
|
screenReader: undefined,
|
||||||
vlmSwitchMode: undefined,
|
vlmSwitchMode: undefined,
|
||||||
useSmartEdit: undefined,
|
useSmartEdit: undefined,
|
||||||
|
inputFormat: undefined,
|
||||||
outputFormat: undefined,
|
outputFormat: undefined,
|
||||||
|
includePartialMessages: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await main();
|
await main();
|
||||||
@@ -412,6 +553,7 @@ describe('startInteractiveUI', () => {
|
|||||||
vi.mock('./utils/cleanup.js', () => ({
|
vi.mock('./utils/cleanup.js', () => ({
|
||||||
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
|
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
|
||||||
registerCleanup: vi.fn(),
|
registerCleanup: vi.fn(),
|
||||||
|
runExitCleanup: vi.fn(() => Promise.resolve()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('ink', () => ({
|
vi.mock('ink', () => ({
|
||||||
|
|||||||
@@ -4,58 +4,60 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
AuthType,
|
||||||
|
getOauthClient,
|
||||||
|
InputFormat,
|
||||||
|
logUserPrompt,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
import { render } from 'ink';
|
import { render } from 'ink';
|
||||||
import { AppContainer } from './ui/AppContainer.js';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
import dns from 'node:dns';
|
||||||
import * as cliConfig from './config/config.js';
|
import os from 'node:os';
|
||||||
import { readStdin } from './utils/readStdin.js';
|
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
import v8 from 'node:v8';
|
import v8 from 'node:v8';
|
||||||
import os from 'node:os';
|
import React from 'react';
|
||||||
import dns from 'node:dns';
|
import { validateAuthMethod } from './config/auth.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import * as cliConfig from './config/config.js';
|
||||||
import { start_sandbox } from './utils/sandbox.js';
|
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||||
|
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||||
import { themeManager } from './ui/themes/theme-manager.js';
|
import {
|
||||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
initializeApp,
|
||||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
type InitializationResult,
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
} from './core/initializer.js';
|
||||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
import { runNonInteractiveStreamJson } from './nonInteractive/session.js';
|
||||||
|
import { AppContainer } from './ui/AppContainer.js';
|
||||||
|
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||||
|
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||||
|
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||||
|
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||||
|
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||||
|
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||||
|
import { themeManager } from './ui/themes/theme-manager.js';
|
||||||
|
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||||
|
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
||||||
|
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||||
import {
|
import {
|
||||||
cleanupCheckpoints,
|
cleanupCheckpoints,
|
||||||
registerCleanup,
|
registerCleanup,
|
||||||
runExitCleanup,
|
runExitCleanup,
|
||||||
} from './utils/cleanup.js';
|
} from './utils/cleanup.js';
|
||||||
import { getCliVersion } from './utils/version.js';
|
import { AppEvent, appEvents } from './utils/events.js';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
|
||||||
import {
|
|
||||||
AuthType,
|
|
||||||
getOauthClient,
|
|
||||||
logUserPrompt,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import {
|
|
||||||
initializeApp,
|
|
||||||
type InitializationResult,
|
|
||||||
} from './core/initializer.js';
|
|
||||||
import { validateAuthMethod } from './config/auth.js';
|
|
||||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
|
||||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
|
||||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
|
||||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
|
||||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
import { readStdin } from './utils/readStdin.js';
|
||||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
|
||||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
|
||||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
|
||||||
import { appEvents, AppEvent } from './utils/events.js';
|
|
||||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
|
||||||
import {
|
import {
|
||||||
relaunchOnExitCode,
|
|
||||||
relaunchAppInChildProcess,
|
relaunchAppInChildProcess,
|
||||||
|
relaunchOnExitCode,
|
||||||
} from './utils/relaunch.js';
|
} from './utils/relaunch.js';
|
||||||
|
import { start_sandbox } from './utils/sandbox.js';
|
||||||
|
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||||
|
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||||
|
import { getCliVersion } from './utils/version.js';
|
||||||
|
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||||
|
|
||||||
export function validateDnsResolutionOrder(
|
export function validateDnsResolutionOrder(
|
||||||
@@ -106,9 +108,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
|
||||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
|
||||||
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
||||||
|
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||||
|
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||||
|
|
||||||
export function setupUnhandledRejectionHandler() {
|
export function setupUnhandledRejectionHandler() {
|
||||||
let unhandledRejectionOccurred = false;
|
let unhandledRejectionOccurred = false;
|
||||||
@@ -218,12 +220,6 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||||
const consolePatcher = new ConsolePatcher({
|
|
||||||
stderr: true,
|
|
||||||
debugMode: isDebugMode,
|
|
||||||
});
|
|
||||||
consolePatcher.patch();
|
|
||||||
registerCleanup(consolePatcher.cleanup);
|
|
||||||
|
|
||||||
dns.setDefaultResultOrder(
|
dns.setDefaultResultOrder(
|
||||||
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
|
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
|
||||||
@@ -348,6 +344,15 @@ export async function main() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup unified ConsolePatcher based on interactive mode
|
||||||
|
const isInteractive = config.isInteractive();
|
||||||
|
const consolePatcher = new ConsolePatcher({
|
||||||
|
stderr: isInteractive,
|
||||||
|
debugMode: isDebugMode,
|
||||||
|
});
|
||||||
|
consolePatcher.patch();
|
||||||
|
registerCleanup(consolePatcher.cleanup);
|
||||||
|
|
||||||
const wasRaw = process.stdin.isRaw;
|
const wasRaw = process.stdin.isRaw;
|
||||||
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
|
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
|
||||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||||
@@ -410,14 +415,43 @@ export async function main() {
|
|||||||
|
|
||||||
await config.initialize();
|
await config.initialize();
|
||||||
|
|
||||||
// If not a TTY, read from stdin
|
// Check input format BEFORE reading stdin
|
||||||
// This is for cases where the user pipes input directly into the command
|
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
|
||||||
if (!process.stdin.isTTY) {
|
const inputFormat =
|
||||||
|
typeof config.getInputFormat === 'function'
|
||||||
|
? config.getInputFormat()
|
||||||
|
: InputFormat.TEXT;
|
||||||
|
|
||||||
|
// Only read stdin if NOT in stream-json mode
|
||||||
|
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
|
||||||
|
// and should be consumed by StreamJsonInputReader instead
|
||||||
|
if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) {
|
||||||
const stdinData = await readStdin();
|
const stdinData = await readStdin();
|
||||||
if (stdinData) {
|
if (stdinData) {
|
||||||
input = `${stdinData}\n\n${input}`;
|
input = `${stdinData}\n\n${input}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||||
|
settings.merged.security?.auth?.selectedType,
|
||||||
|
settings.merged.security?.auth?.useExternal,
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt_id = Math.random().toString(16).slice(2);
|
||||||
|
|
||||||
|
if (inputFormat === InputFormat.STREAM_JSON) {
|
||||||
|
const trimmedInput = (input ?? '').trim();
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(
|
||||||
|
nonInteractiveConfig,
|
||||||
|
trimmedInput.length > 0 ? trimmedInput : '',
|
||||||
|
);
|
||||||
|
await runExitCleanup();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
console.error(
|
console.error(
|
||||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
||||||
@@ -425,7 +459,6 @@ export async function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt_id = Math.random().toString(16).slice(2);
|
|
||||||
logUserPrompt(config, {
|
logUserPrompt(config, {
|
||||||
'event.name': 'user_prompt',
|
'event.name': 'user_prompt',
|
||||||
'event.timestamp': new Date().toISOString(),
|
'event.timestamp': new Date().toISOString(),
|
||||||
@@ -435,13 +468,6 @@ export async function main() {
|
|||||||
prompt_length: input.length,
|
prompt_length: input.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
|
||||||
settings.merged.security?.auth?.selectedType,
|
|
||||||
settings.merged.security?.auth?.useExternal,
|
|
||||||
config,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (config.getDebugMode()) {
|
if (config.getDebugMode()) {
|
||||||
console.log('Session ID: %s', sessionId);
|
console.log('Session ID: %s', sessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
76
packages/cli/src/nonInteractive/control/ControlContext.ts
Normal file
76
packages/cli/src/nonInteractive/control/ControlContext.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control Context
|
||||||
|
*
|
||||||
|
* Layer 1 of the control plane architecture. Provides shared, session-scoped
|
||||||
|
* state for all controllers and services, eliminating the need for prop
|
||||||
|
* drilling. Mutable fields are intentionally exposed so controllers can track
|
||||||
|
* runtime state (e.g. permission mode, active MCP clients).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
|
||||||
|
import type { PermissionMode } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control Context interface
|
||||||
|
*
|
||||||
|
* Provides shared access to session-scoped resources and mutable state
|
||||||
|
* for all controllers across both ControlDispatcher (protocol routing) and
|
||||||
|
* ControlService (programmatic API).
|
||||||
|
*/
|
||||||
|
export interface IControlContext {
|
||||||
|
readonly config: Config;
|
||||||
|
readonly streamJson: StreamJsonOutputAdapter;
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly abortSignal: AbortSignal;
|
||||||
|
readonly debugMode: boolean;
|
||||||
|
|
||||||
|
permissionMode: PermissionMode;
|
||||||
|
sdkMcpServers: Set<string>;
|
||||||
|
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
|
||||||
|
|
||||||
|
onInterrupt?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control Context implementation
|
||||||
|
*/
|
||||||
|
export class ControlContext implements IControlContext {
|
||||||
|
readonly config: Config;
|
||||||
|
readonly streamJson: StreamJsonOutputAdapter;
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly abortSignal: AbortSignal;
|
||||||
|
readonly debugMode: boolean;
|
||||||
|
|
||||||
|
permissionMode: PermissionMode;
|
||||||
|
sdkMcpServers: Set<string>;
|
||||||
|
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
|
||||||
|
|
||||||
|
onInterrupt?: () => void;
|
||||||
|
|
||||||
|
constructor(options: {
|
||||||
|
config: Config;
|
||||||
|
streamJson: StreamJsonOutputAdapter;
|
||||||
|
sessionId: string;
|
||||||
|
abortSignal: AbortSignal;
|
||||||
|
permissionMode?: PermissionMode;
|
||||||
|
onInterrupt?: () => void;
|
||||||
|
}) {
|
||||||
|
this.config = options.config;
|
||||||
|
this.streamJson = options.streamJson;
|
||||||
|
this.sessionId = options.sessionId;
|
||||||
|
this.abortSignal = options.abortSignal;
|
||||||
|
this.debugMode = options.config.getDebugMode();
|
||||||
|
this.permissionMode = options.permissionMode || 'default';
|
||||||
|
this.sdkMcpServers = new Set();
|
||||||
|
this.mcpClients = new Map();
|
||||||
|
this.onInterrupt = options.onInterrupt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,924 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { ControlDispatcher } from './ControlDispatcher.js';
|
||||||
|
import type { IControlContext } from './ControlContext.js';
|
||||||
|
import type { SystemController } from './controllers/systemController.js';
|
||||||
|
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
|
||||||
|
import type {
|
||||||
|
CLIControlRequest,
|
||||||
|
CLIControlResponse,
|
||||||
|
ControlResponse,
|
||||||
|
ControlRequestPayload,
|
||||||
|
CLIControlInitializeRequest,
|
||||||
|
CLIControlInterruptRequest,
|
||||||
|
CLIControlSetModelRequest,
|
||||||
|
CLIControlSupportedCommandsRequest,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock control context for testing
|
||||||
|
*/
|
||||||
|
function createMockContext(debugMode: boolean = false): IControlContext {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const mockStreamJson = {
|
||||||
|
send: vi.fn(),
|
||||||
|
} as unknown as StreamJsonOutputAdapter;
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
getDebugMode: vi.fn().mockReturnValue(debugMode),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: mockConfig as unknown as IControlContext['config'],
|
||||||
|
streamJson: mockStreamJson,
|
||||||
|
sessionId: 'test-session-id',
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
debugMode,
|
||||||
|
permissionMode: 'default',
|
||||||
|
sdkMcpServers: new Set<string>(),
|
||||||
|
mcpClients: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a mock system controller for testing
|
||||||
|
*/
|
||||||
|
function createMockSystemController() {
|
||||||
|
return {
|
||||||
|
handleRequest: vi.fn(),
|
||||||
|
sendControlRequest: vi.fn(),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
} as unknown as SystemController;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ControlDispatcher', () => {
|
||||||
|
let dispatcher: ControlDispatcher;
|
||||||
|
let mockContext: IControlContext;
|
||||||
|
let mockSystemController: SystemController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockContext = createMockContext();
|
||||||
|
mockSystemController = createMockSystemController();
|
||||||
|
|
||||||
|
// Mock SystemController constructor
|
||||||
|
vi.doMock('./controllers/systemController.js', () => ({
|
||||||
|
SystemController: vi.fn().mockImplementation(() => mockSystemController),
|
||||||
|
}));
|
||||||
|
|
||||||
|
dispatcher = new ControlDispatcher(mockContext);
|
||||||
|
// Replace with mock controller for easier testing
|
||||||
|
(
|
||||||
|
dispatcher as unknown as { systemController: SystemController }
|
||||||
|
).systemController = mockSystemController;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should initialize with context and create controllers', () => {
|
||||||
|
expect(dispatcher).toBeDefined();
|
||||||
|
expect(dispatcher.systemController).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should listen to abort signal and shutdown when aborted', () => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
...createMockContext(),
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newDispatcher = new ControlDispatcher(context);
|
||||||
|
vi.spyOn(newDispatcher, 'shutdown');
|
||||||
|
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
// Give event loop a chance to process
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setImmediate(() => {
|
||||||
|
expect(newDispatcher.shutdown).toHaveBeenCalled();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispatch', () => {
|
||||||
|
it('should route initialize request to system controller', async () => {
|
||||||
|
const request: CLIControlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-1',
|
||||||
|
request: {
|
||||||
|
subtype: 'initialize',
|
||||||
|
} as CLIControlInitializeRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
subtype: 'initialize',
|
||||||
|
capabilities: { test: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||||
|
mockResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
|
||||||
|
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||||
|
request.request,
|
||||||
|
'req-1',
|
||||||
|
);
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: 'req-1',
|
||||||
|
response: mockResponse,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route interrupt request to system controller', async () => {
|
||||||
|
const request: CLIControlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-2',
|
||||||
|
request: {
|
||||||
|
subtype: 'interrupt',
|
||||||
|
} as CLIControlInterruptRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse = { subtype: 'interrupt' };
|
||||||
|
|
||||||
|
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||||
|
mockResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
|
||||||
|
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||||
|
request.request,
|
||||||
|
'req-2',
|
||||||
|
);
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: 'req-2',
|
||||||
|
response: mockResponse,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route set_model request to system controller', async () => {
|
||||||
|
const request: CLIControlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-3',
|
||||||
|
request: {
|
||||||
|
subtype: 'set_model',
|
||||||
|
model: 'test-model',
|
||||||
|
} as CLIControlSetModelRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
subtype: 'set_model',
|
||||||
|
model: 'test-model',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||||
|
mockResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
|
||||||
|
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||||
|
request.request,
|
||||||
|
'req-3',
|
||||||
|
);
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: 'req-3',
|
||||||
|
response: mockResponse,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route supported_commands request to system controller', async () => {
|
||||||
|
const request: CLIControlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-4',
|
||||||
|
request: {
|
||||||
|
subtype: 'supported_commands',
|
||||||
|
} as CLIControlSupportedCommandsRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
subtype: 'supported_commands',
|
||||||
|
commands: ['initialize', 'interrupt'],
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||||
|
mockResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
|
||||||
|
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||||
|
request.request,
|
||||||
|
'req-4',
|
||||||
|
);
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: 'req-4',
|
||||||
|
response: mockResponse,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send error response when controller throws error', async () => {
|
||||||
|
const request: CLIControlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-5',
|
||||||
|
request: {
|
||||||
|
subtype: 'initialize',
|
||||||
|
} as CLIControlInitializeRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = new Error('Test error');
|
||||||
|
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: 'req-5',
|
||||||
|
error: 'Test error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-Error thrown values', async () => {
|
||||||
|
const request: CLIControlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-6',
|
||||||
|
request: {
|
||||||
|
subtype: 'initialize',
|
||||||
|
} as CLIControlInitializeRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(
|
||||||
|
'String error',
|
||||||
|
);
|
||||||
|
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: 'req-6',
|
||||||
|
error: 'String error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send error response for unknown request subtype', async () => {
|
||||||
|
const request = {
|
||||||
|
type: 'control_request' as const,
|
||||||
|
request_id: 'req-7',
|
||||||
|
request: {
|
||||||
|
subtype: 'unknown_subtype',
|
||||||
|
} as unknown as ControlRequestPayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
|
||||||
|
// Dispatch catches errors and sends error response instead of throwing
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: 'req-7',
|
||||||
|
error: 'Unknown control request subtype: unknown_subtype',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleControlResponse', () => {
|
||||||
|
it('should resolve pending outgoing request on success response', () => {
|
||||||
|
const requestId = 'outgoing-req-1';
|
||||||
|
const response: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response: { result: 'success' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register a pending outgoing request
|
||||||
|
const resolve = vi.fn();
|
||||||
|
const reject = vi.fn();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
// Access private method through type casting
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerOutgoingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
resolve: (r: ControlResponse) => void,
|
||||||
|
reject: (e: Error) => void,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerOutgoingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatcher.handleControlResponse(response);
|
||||||
|
|
||||||
|
expect(resolve).toHaveBeenCalledWith(response.response);
|
||||||
|
expect(reject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject pending outgoing request on error response', () => {
|
||||||
|
const requestId = 'outgoing-req-2';
|
||||||
|
const response: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: requestId,
|
||||||
|
error: 'Request failed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = vi.fn();
|
||||||
|
const reject = vi.fn();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerOutgoingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
resolve: (r: ControlResponse) => void,
|
||||||
|
reject: (e: Error) => void,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerOutgoingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatcher.handleControlResponse(response);
|
||||||
|
|
||||||
|
expect(reject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Request failed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(resolve).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error object in error response', () => {
|
||||||
|
const requestId = 'outgoing-req-3';
|
||||||
|
const response: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: requestId,
|
||||||
|
error: { message: 'Detailed error', code: 500 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = vi.fn();
|
||||||
|
const reject = vi.fn();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerOutgoingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
resolve: (r: ControlResponse) => void,
|
||||||
|
reject: (e: Error) => void,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerOutgoingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatcher.handleControlResponse(response);
|
||||||
|
|
||||||
|
expect(reject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Detailed error',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle response for non-existent pending request gracefully', () => {
|
||||||
|
const response: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: 'non-existent',
|
||||||
|
response: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => dispatcher.handleControlResponse(response)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle response for non-existent request in debug mode', () => {
|
||||||
|
const context = createMockContext(true);
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||||
|
const response: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: 'non-existent',
|
||||||
|
response: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatcherWithDebug.handleControlResponse(response);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'[ControlDispatcher] No pending outgoing request for: non-existent',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendControlRequest', () => {
|
||||||
|
it('should delegate to system controller sendControlRequest', async () => {
|
||||||
|
const payload: ControlRequestPayload = {
|
||||||
|
subtype: 'initialize',
|
||||||
|
} as CLIControlInitializeRequest;
|
||||||
|
|
||||||
|
const expectedResponse: ControlResponse = {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: 'test-id',
|
||||||
|
response: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockSystemController.sendControlRequest).mockResolvedValue(
|
||||||
|
expectedResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await dispatcher.sendControlRequest(payload, 5000);
|
||||||
|
|
||||||
|
expect(mockSystemController.sendControlRequest).toHaveBeenCalledWith(
|
||||||
|
payload,
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
expect(result).toBe(expectedResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleCancel', () => {
|
||||||
|
it('should cancel specific incoming request', () => {
|
||||||
|
const requestId = 'cancel-req-1';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
const abortSpy = vi.spyOn(abortController, 'abort');
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerIncomingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerIncomingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
abortController,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatcher.handleCancel(requestId);
|
||||||
|
|
||||||
|
expect(abortSpy).toHaveBeenCalled();
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: requestId,
|
||||||
|
error: 'Request cancelled',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel all incoming requests when no requestId provided', () => {
|
||||||
|
const requestId1 = 'cancel-req-2';
|
||||||
|
const requestId2 = 'cancel-req-3';
|
||||||
|
|
||||||
|
const abortController1 = new AbortController();
|
||||||
|
const abortController2 = new AbortController();
|
||||||
|
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||||
|
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
const abortSpy1 = vi.spyOn(abortController1, 'abort');
|
||||||
|
const abortSpy2 = vi.spyOn(abortController2, 'abort');
|
||||||
|
|
||||||
|
const register = (
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerIncomingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerIncomingRequest.bind(dispatcher);
|
||||||
|
|
||||||
|
register(requestId1, 'SystemController', abortController1, timeoutId1);
|
||||||
|
register(requestId2, 'SystemController', abortController2, timeoutId2);
|
||||||
|
|
||||||
|
dispatcher.handleCancel();
|
||||||
|
|
||||||
|
expect(abortSpy1).toHaveBeenCalled();
|
||||||
|
expect(abortSpy2).toHaveBeenCalled();
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: requestId1,
|
||||||
|
error: 'All requests cancelled',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: requestId2,
|
||||||
|
error: 'All requests cancelled',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cancel of non-existent request gracefully', () => {
|
||||||
|
expect(() => dispatcher.handleCancel('non-existent')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log cancellation in debug mode', () => {
|
||||||
|
const context = createMockContext(true);
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||||
|
const requestId = 'cancel-req-debug';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcherWithDebug as unknown as {
|
||||||
|
registerIncomingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerIncomingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
abortController,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatcherWithDebug.handleCancel(requestId);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'[ControlDispatcher] Cancelled incoming request: cancel-req-debug',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shutdown', () => {
|
||||||
|
it('should cancel all pending incoming requests', () => {
|
||||||
|
const requestId1 = 'shutdown-req-1';
|
||||||
|
const requestId2 = 'shutdown-req-2';
|
||||||
|
|
||||||
|
const abortController1 = new AbortController();
|
||||||
|
const abortController2 = new AbortController();
|
||||||
|
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||||
|
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
const abortSpy1 = vi.spyOn(abortController1, 'abort');
|
||||||
|
const abortSpy2 = vi.spyOn(abortController2, 'abort');
|
||||||
|
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||||
|
|
||||||
|
const register = (
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerIncomingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerIncomingRequest.bind(dispatcher);
|
||||||
|
|
||||||
|
register(requestId1, 'SystemController', abortController1, timeoutId1);
|
||||||
|
register(requestId2, 'SystemController', abortController2, timeoutId2);
|
||||||
|
|
||||||
|
dispatcher.shutdown();
|
||||||
|
|
||||||
|
expect(abortSpy1).toHaveBeenCalled();
|
||||||
|
expect(abortSpy2).toHaveBeenCalled();
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject all pending outgoing requests', () => {
|
||||||
|
const requestId1 = 'outgoing-shutdown-1';
|
||||||
|
const requestId2 = 'outgoing-shutdown-2';
|
||||||
|
|
||||||
|
const reject1 = vi.fn();
|
||||||
|
const reject2 = vi.fn();
|
||||||
|
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||||
|
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||||
|
|
||||||
|
const register = (
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerOutgoingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
resolve: (r: ControlResponse) => void,
|
||||||
|
reject: (e: Error) => void,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerOutgoingRequest.bind(dispatcher);
|
||||||
|
|
||||||
|
register(requestId1, 'SystemController', vi.fn(), reject1, timeoutId1);
|
||||||
|
register(requestId2, 'SystemController', vi.fn(), reject2, timeoutId2);
|
||||||
|
|
||||||
|
dispatcher.shutdown();
|
||||||
|
|
||||||
|
expect(reject1).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Dispatcher shutdown',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(reject2).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: 'Dispatcher shutdown',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cleanup all controllers', () => {
|
||||||
|
vi.mocked(mockSystemController.cleanup).mockImplementation(() => {});
|
||||||
|
|
||||||
|
dispatcher.shutdown();
|
||||||
|
|
||||||
|
expect(mockSystemController.cleanup).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log shutdown in debug mode', () => {
|
||||||
|
const context = createMockContext(true);
|
||||||
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||||
|
|
||||||
|
dispatcherWithDebug.shutdown();
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'[ControlDispatcher] Shutting down',
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pending request registry', () => {
|
||||||
|
describe('registerIncomingRequest', () => {
|
||||||
|
it('should register incoming request', () => {
|
||||||
|
const requestId = 'reg-incoming-1';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerIncomingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerIncomingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
abortController,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify it was registered by trying to cancel it
|
||||||
|
dispatcher.handleCancel(requestId);
|
||||||
|
expect(abortController.signal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deregisterIncomingRequest', () => {
|
||||||
|
it('should deregister incoming request', () => {
|
||||||
|
const requestId = 'dereg-incoming-1';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerIncomingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
deregisterIncomingRequest: (id: string) => void;
|
||||||
|
}
|
||||||
|
).registerIncomingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
abortController,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
deregisterIncomingRequest: (id: string) => void;
|
||||||
|
}
|
||||||
|
).deregisterIncomingRequest(requestId);
|
||||||
|
|
||||||
|
// Verify it was deregistered - cancel should not find it
|
||||||
|
const sendMock = vi.mocked(mockContext.streamJson.send);
|
||||||
|
const sendCallCount = sendMock.mock.calls.length;
|
||||||
|
dispatcher.handleCancel(requestId);
|
||||||
|
// Should not send cancel response for non-existent request
|
||||||
|
expect(sendMock.mock.calls.length).toBe(sendCallCount);
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deregister of non-existent request gracefully', () => {
|
||||||
|
expect(() => {
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
deregisterIncomingRequest: (id: string) => void;
|
||||||
|
}
|
||||||
|
).deregisterIncomingRequest('non-existent');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerOutgoingRequest', () => {
|
||||||
|
it('should register outgoing request', () => {
|
||||||
|
const requestId = 'reg-outgoing-1';
|
||||||
|
const resolve = vi.fn();
|
||||||
|
const reject = vi.fn();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerOutgoingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
resolve: (r: ControlResponse) => void,
|
||||||
|
reject: (e: Error) => void,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).registerOutgoingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify it was registered by handling a response
|
||||||
|
const response: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatcher.handleControlResponse(response);
|
||||||
|
expect(resolve).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deregisterOutgoingRequest', () => {
|
||||||
|
it('should deregister outgoing request', () => {
|
||||||
|
const requestId = 'dereg-outgoing-1';
|
||||||
|
const resolve = vi.fn();
|
||||||
|
const reject = vi.fn();
|
||||||
|
const timeoutId = setTimeout(() => {}, 1000);
|
||||||
|
|
||||||
|
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
registerOutgoingRequest: (
|
||||||
|
id: string,
|
||||||
|
controller: string,
|
||||||
|
resolve: (r: ControlResponse) => void,
|
||||||
|
reject: (e: Error) => void,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
) => void;
|
||||||
|
deregisterOutgoingRequest: (id: string) => void;
|
||||||
|
}
|
||||||
|
).registerOutgoingRequest(
|
||||||
|
requestId,
|
||||||
|
'SystemController',
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
deregisterOutgoingRequest: (id: string) => void;
|
||||||
|
}
|
||||||
|
).deregisterOutgoingRequest(requestId);
|
||||||
|
|
||||||
|
// Verify it was deregistered - response should not find it
|
||||||
|
const response: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatcher.handleControlResponse(response);
|
||||||
|
expect(resolve).not.toHaveBeenCalled();
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deregister of non-existent request gracefully', () => {
|
||||||
|
expect(() => {
|
||||||
|
(
|
||||||
|
dispatcher as unknown as {
|
||||||
|
deregisterOutgoingRequest: (id: string) => void;
|
||||||
|
}
|
||||||
|
).deregisterOutgoingRequest('non-existent');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
353
packages/cli/src/nonInteractive/control/ControlDispatcher.ts
Normal file
353
packages/cli/src/nonInteractive/control/ControlDispatcher.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control Dispatcher
|
||||||
|
*
|
||||||
|
* Layer 2 of the control plane architecture. Routes control requests between
|
||||||
|
* SDK and CLI to appropriate controllers, manages pending request registries,
|
||||||
|
* and handles cancellation/cleanup. Application code MUST NOT depend on
|
||||||
|
* controller instances exposed by this class; instead, use ControlService,
|
||||||
|
* which wraps these controllers with a stable programmatic API.
|
||||||
|
*
|
||||||
|
* Controllers:
|
||||||
|
* - SystemController: initialize, interrupt, set_model, supported_commands
|
||||||
|
* - PermissionController: can_use_tool, set_permission_mode
|
||||||
|
* - MCPController: mcp_message, mcp_server_status
|
||||||
|
* - HookController: hook_callback
|
||||||
|
*
|
||||||
|
* Note: Control request types are centrally defined in the ControlRequestType
|
||||||
|
* enum in packages/sdk/typescript/src/types/controlRequests.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IControlContext } from './ControlContext.js';
|
||||||
|
import type { IPendingRequestRegistry } from './controllers/baseController.js';
|
||||||
|
import { SystemController } from './controllers/systemController.js';
|
||||||
|
// import { PermissionController } from './controllers/permissionController.js';
|
||||||
|
// import { MCPController } from './controllers/mcpController.js';
|
||||||
|
// import { HookController } from './controllers/hookController.js';
|
||||||
|
import type {
|
||||||
|
CLIControlRequest,
|
||||||
|
CLIControlResponse,
|
||||||
|
ControlResponse,
|
||||||
|
ControlRequestPayload,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks an incoming request from SDK awaiting CLI response
|
||||||
|
*/
|
||||||
|
interface PendingIncomingRequest {
|
||||||
|
controller: string;
|
||||||
|
abortController: AbortController;
|
||||||
|
timeoutId: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks an outgoing request from CLI awaiting SDK response
|
||||||
|
*/
|
||||||
|
interface PendingOutgoingRequest {
|
||||||
|
controller: string;
|
||||||
|
resolve: (response: ControlResponse) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeoutId: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central coordinator for control plane communication.
|
||||||
|
* Routes requests to controllers and manages request lifecycle.
|
||||||
|
*/
|
||||||
|
export class ControlDispatcher implements IPendingRequestRegistry {
|
||||||
|
private context: IControlContext;
|
||||||
|
|
||||||
|
// Make controllers publicly accessible
|
||||||
|
readonly systemController: SystemController;
|
||||||
|
// readonly permissionController: PermissionController;
|
||||||
|
// readonly mcpController: MCPController;
|
||||||
|
// readonly hookController: HookController;
|
||||||
|
|
||||||
|
// Central pending request registries
|
||||||
|
private pendingIncomingRequests: Map<string, PendingIncomingRequest> =
|
||||||
|
new Map();
|
||||||
|
private pendingOutgoingRequests: Map<string, PendingOutgoingRequest> =
|
||||||
|
new Map();
|
||||||
|
|
||||||
|
constructor(context: IControlContext) {
|
||||||
|
this.context = context;
|
||||||
|
|
||||||
|
// Create domain controllers with context and registry
|
||||||
|
this.systemController = new SystemController(
|
||||||
|
context,
|
||||||
|
this,
|
||||||
|
'SystemController',
|
||||||
|
);
|
||||||
|
// this.permissionController = new PermissionController(
|
||||||
|
// context,
|
||||||
|
// this,
|
||||||
|
// 'PermissionController',
|
||||||
|
// );
|
||||||
|
// this.mcpController = new MCPController(context, this, 'MCPController');
|
||||||
|
// this.hookController = new HookController(context, this, 'HookController');
|
||||||
|
|
||||||
|
// Listen for main abort signal
|
||||||
|
this.context.abortSignal.addEventListener('abort', () => {
|
||||||
|
this.shutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes an incoming request to the appropriate controller and sends response
|
||||||
|
*/
|
||||||
|
async dispatch(request: CLIControlRequest): Promise<void> {
|
||||||
|
const { request_id, request: payload } = request;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Route to appropriate controller
|
||||||
|
const controller = this.getControllerForRequest(payload.subtype);
|
||||||
|
const response = await controller.handleRequest(payload, request_id);
|
||||||
|
|
||||||
|
// Send success response
|
||||||
|
this.sendSuccessResponse(request_id, response);
|
||||||
|
} catch (error) {
|
||||||
|
// Send error response
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
this.sendErrorResponse(request_id, errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes response from SDK for an outgoing request
|
||||||
|
*/
|
||||||
|
handleControlResponse(response: CLIControlResponse): void {
|
||||||
|
const responsePayload = response.response;
|
||||||
|
const requestId = responsePayload.request_id;
|
||||||
|
|
||||||
|
const pending = this.pendingOutgoingRequests.get(requestId);
|
||||||
|
if (!pending) {
|
||||||
|
// No pending request found - may have timed out or been cancelled
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[ControlDispatcher] No pending outgoing request for: ${requestId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deregister
|
||||||
|
this.deregisterOutgoingRequest(requestId);
|
||||||
|
|
||||||
|
// Resolve or reject based on response type
|
||||||
|
if (responsePayload.subtype === 'success') {
|
||||||
|
pending.resolve(responsePayload);
|
||||||
|
} else {
|
||||||
|
const errorMessage =
|
||||||
|
typeof responsePayload.error === 'string'
|
||||||
|
? responsePayload.error
|
||||||
|
: (responsePayload.error?.message ?? 'Unknown error');
|
||||||
|
pending.reject(new Error(errorMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a control request to SDK and waits for response
|
||||||
|
*/
|
||||||
|
async sendControlRequest(
|
||||||
|
payload: ControlRequestPayload,
|
||||||
|
timeoutMs?: number,
|
||||||
|
): Promise<ControlResponse> {
|
||||||
|
// Delegate to system controller (or any controller, they all have the same method)
|
||||||
|
return this.systemController.sendControlRequest(payload, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels a specific request or all pending requests
|
||||||
|
*/
|
||||||
|
handleCancel(requestId?: string): void {
|
||||||
|
if (requestId) {
|
||||||
|
// Cancel specific incoming request
|
||||||
|
const pending = this.pendingIncomingRequests.get(requestId);
|
||||||
|
if (pending) {
|
||||||
|
pending.abortController.abort();
|
||||||
|
this.deregisterIncomingRequest(requestId);
|
||||||
|
this.sendErrorResponse(requestId, 'Request cancelled');
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[ControlDispatcher] Cancelled incoming request: ${requestId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Cancel ALL pending incoming requests
|
||||||
|
const requestIds = Array.from(this.pendingIncomingRequests.keys());
|
||||||
|
for (const id of requestIds) {
|
||||||
|
const pending = this.pendingIncomingRequests.get(id);
|
||||||
|
if (pending) {
|
||||||
|
pending.abortController.abort();
|
||||||
|
this.deregisterIncomingRequest(id);
|
||||||
|
this.sendErrorResponse(id, 'All requests cancelled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops all pending requests and cleans up all controllers
|
||||||
|
*/
|
||||||
|
shutdown(): void {
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error('[ControlDispatcher] Shutting down');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel all incoming requests
|
||||||
|
for (const [
|
||||||
|
_requestId,
|
||||||
|
pending,
|
||||||
|
] of this.pendingIncomingRequests.entries()) {
|
||||||
|
pending.abortController.abort();
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
}
|
||||||
|
this.pendingIncomingRequests.clear();
|
||||||
|
|
||||||
|
// Cancel all outgoing requests
|
||||||
|
for (const [
|
||||||
|
_requestId,
|
||||||
|
pending,
|
||||||
|
] of this.pendingOutgoingRequests.entries()) {
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
pending.reject(new Error('Dispatcher shutdown'));
|
||||||
|
}
|
||||||
|
this.pendingOutgoingRequests.clear();
|
||||||
|
|
||||||
|
// Cleanup controllers (MCP controller will close all clients)
|
||||||
|
this.systemController.cleanup();
|
||||||
|
// this.permissionController.cleanup();
|
||||||
|
// this.mcpController.cleanup();
|
||||||
|
// this.hookController.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers an incoming request in the pending registry
|
||||||
|
*/
|
||||||
|
registerIncomingRequest(
|
||||||
|
requestId: string,
|
||||||
|
controller: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
): void {
|
||||||
|
this.pendingIncomingRequests.set(requestId, {
|
||||||
|
controller,
|
||||||
|
abortController,
|
||||||
|
timeoutId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an incoming request from the pending registry
|
||||||
|
*/
|
||||||
|
deregisterIncomingRequest(requestId: string): void {
|
||||||
|
const pending = this.pendingIncomingRequests.get(requestId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
this.pendingIncomingRequests.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers an outgoing request in the pending registry
|
||||||
|
*/
|
||||||
|
registerOutgoingRequest(
|
||||||
|
requestId: string,
|
||||||
|
controller: string,
|
||||||
|
resolve: (response: ControlResponse) => void,
|
||||||
|
reject: (error: Error) => void,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
): void {
|
||||||
|
this.pendingOutgoingRequests.set(requestId, {
|
||||||
|
controller,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an outgoing request from the pending registry
|
||||||
|
*/
|
||||||
|
deregisterOutgoingRequest(requestId: string): void {
|
||||||
|
const pending = this.pendingOutgoingRequests.get(requestId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeoutId);
|
||||||
|
this.pendingOutgoingRequests.delete(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the controller that handles the given request subtype
|
||||||
|
*/
|
||||||
|
private getControllerForRequest(subtype: string) {
|
||||||
|
switch (subtype) {
|
||||||
|
case 'initialize':
|
||||||
|
case 'interrupt':
|
||||||
|
case 'set_model':
|
||||||
|
case 'supported_commands':
|
||||||
|
return this.systemController;
|
||||||
|
|
||||||
|
// case 'can_use_tool':
|
||||||
|
// case 'set_permission_mode':
|
||||||
|
// return this.permissionController;
|
||||||
|
|
||||||
|
// case 'mcp_message':
|
||||||
|
// case 'mcp_server_status':
|
||||||
|
// return this.mcpController;
|
||||||
|
|
||||||
|
// case 'hook_callback':
|
||||||
|
// return this.hookController;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown control request subtype: ${subtype}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a success response back to SDK
|
||||||
|
*/
|
||||||
|
private sendSuccessResponse(
|
||||||
|
requestId: string,
|
||||||
|
response: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const controlResponse: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.context.streamJson.send(controlResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an error response back to SDK
|
||||||
|
*/
|
||||||
|
private sendErrorResponse(requestId: string, error: string): void {
|
||||||
|
const controlResponse: CLIControlResponse = {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'error',
|
||||||
|
request_id: requestId,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.context.streamJson.send(controlResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
packages/cli/src/nonInteractive/control/ControlService.ts
Normal file
191
packages/cli/src/nonInteractive/control/ControlService.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control Service - Public Programmatic API
|
||||||
|
*
|
||||||
|
* Provides type-safe access to control plane functionality for internal
|
||||||
|
* CLI code. This is the ONLY programmatic interface that should be used by:
|
||||||
|
* - nonInteractiveCli
|
||||||
|
* - Session managers
|
||||||
|
* - Tool execution handlers
|
||||||
|
* - Internal CLI logic
|
||||||
|
*
|
||||||
|
* DO NOT use ControlDispatcher or controllers directly from application code.
|
||||||
|
*
|
||||||
|
* Architecture:
|
||||||
|
* - ControlContext stores shared session state (Layer 1)
|
||||||
|
* - ControlDispatcher handles protocol-level routing (Layer 2)
|
||||||
|
* - ControlService provides programmatic API for internal CLI usage (Layer 3)
|
||||||
|
*
|
||||||
|
* ControlService and ControlDispatcher share controller instances to ensure
|
||||||
|
* a single source of truth. All higher level code MUST access the control
|
||||||
|
* plane exclusively through ControlService.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IControlContext } from './ControlContext.js';
|
||||||
|
import type { ControlDispatcher } from './ControlDispatcher.js';
|
||||||
|
import type {
|
||||||
|
// PermissionServiceAPI,
|
||||||
|
SystemServiceAPI,
|
||||||
|
// McpServiceAPI,
|
||||||
|
// HookServiceAPI,
|
||||||
|
} from './types/serviceAPIs.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control Service
|
||||||
|
*
|
||||||
|
* Facade layer providing domain-grouped APIs for control plane operations.
|
||||||
|
* Shares controller instances with ControlDispatcher to ensure single source
|
||||||
|
* of truth and state consistency.
|
||||||
|
*/
|
||||||
|
export class ControlService {
|
||||||
|
private dispatcher: ControlDispatcher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct ControlService
|
||||||
|
*
|
||||||
|
* @param context - Control context (unused directly, passed to dispatcher)
|
||||||
|
* @param dispatcher - Control dispatcher that owns the controller instances
|
||||||
|
*/
|
||||||
|
constructor(context: IControlContext, dispatcher: ControlDispatcher) {
|
||||||
|
this.dispatcher = dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission Domain API
|
||||||
|
*
|
||||||
|
* Handles tool execution permissions, approval checks, and callbacks.
|
||||||
|
* Delegates to the shared PermissionController instance.
|
||||||
|
*/
|
||||||
|
// get permission(): PermissionServiceAPI {
|
||||||
|
// const controller = this.dispatcher.permissionController;
|
||||||
|
// return {
|
||||||
|
// /**
|
||||||
|
// * Check if a tool should be allowed based on current permission settings
|
||||||
|
// *
|
||||||
|
// * Evaluates permission mode and tool registry to determine if execution
|
||||||
|
// * should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||||
|
// *
|
||||||
|
// * @param toolRequest - Tool call request information
|
||||||
|
// * @param confirmationDetails - Optional confirmation details for UI
|
||||||
|
// * @returns Permission decision with optional updated arguments
|
||||||
|
// */
|
||||||
|
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * Build UI suggestions for tool confirmation dialogs
|
||||||
|
// *
|
||||||
|
// * Creates actionable permission suggestions based on tool confirmation details.
|
||||||
|
// *
|
||||||
|
// * @param confirmationDetails - Tool confirmation details
|
||||||
|
// * @returns Array of permission suggestions or null
|
||||||
|
// */
|
||||||
|
// buildPermissionSuggestions:
|
||||||
|
// controller.buildPermissionSuggestions.bind(controller),
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * Get callback for monitoring tool call status updates
|
||||||
|
// *
|
||||||
|
// * Returns callback function for integration with CoreToolScheduler.
|
||||||
|
// *
|
||||||
|
// * @returns Callback function for tool call updates
|
||||||
|
// */
|
||||||
|
// getToolCallUpdateCallback:
|
||||||
|
// controller.getToolCallUpdateCallback.bind(controller),
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System Domain API
|
||||||
|
*
|
||||||
|
* Handles system-level operations and session management.
|
||||||
|
* Delegates to the shared SystemController instance.
|
||||||
|
*/
|
||||||
|
get system(): SystemServiceAPI {
|
||||||
|
const controller = this.dispatcher.systemController;
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Get control capabilities
|
||||||
|
*
|
||||||
|
* Returns the control capabilities object indicating what control
|
||||||
|
* features are available. Used exclusively for the initialize
|
||||||
|
* control response. System messages do not include capabilities.
|
||||||
|
*
|
||||||
|
* @returns Control capabilities object
|
||||||
|
*/
|
||||||
|
getControlCapabilities: () => controller.buildControlCapabilities(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Domain API
|
||||||
|
*
|
||||||
|
* Handles Model Context Protocol server interactions.
|
||||||
|
* Delegates to the shared MCPController instance.
|
||||||
|
*/
|
||||||
|
// get mcp(): McpServiceAPI {
|
||||||
|
// return {
|
||||||
|
// /**
|
||||||
|
// * Get or create MCP client for a server (lazy initialization)
|
||||||
|
// *
|
||||||
|
// * Returns existing client or creates new connection.
|
||||||
|
// *
|
||||||
|
// * @param serverName - Name of the MCP server
|
||||||
|
// * @returns Promise with client and config
|
||||||
|
// */
|
||||||
|
// getMcpClient: async (serverName: string) => {
|
||||||
|
// // MCPController has a private method getOrCreateMcpClient
|
||||||
|
// // We need to expose it via the API
|
||||||
|
// // For now, throw error as placeholder
|
||||||
|
// // The actual implementation will be added when we update MCPController
|
||||||
|
// throw new Error(
|
||||||
|
// `getMcpClient not yet implemented in ControlService. Server: ${serverName}`,
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * List all available MCP servers
|
||||||
|
// *
|
||||||
|
// * Returns names of configured/connected MCP servers.
|
||||||
|
// *
|
||||||
|
// * @returns Array of server names
|
||||||
|
// */
|
||||||
|
// listServers: () => {
|
||||||
|
// // Get servers from context
|
||||||
|
// const sdkServers = Array.from(
|
||||||
|
// this.dispatcher.mcpController['context'].sdkMcpServers,
|
||||||
|
// );
|
||||||
|
// const cliServers = Array.from(
|
||||||
|
// this.dispatcher.mcpController['context'].mcpClients.keys(),
|
||||||
|
// );
|
||||||
|
// return [...new Set([...sdkServers, ...cliServers])];
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook Domain API
|
||||||
|
*
|
||||||
|
* Handles hook callback processing (placeholder for future expansion).
|
||||||
|
* Delegates to the shared HookController instance.
|
||||||
|
*/
|
||||||
|
// get hook(): HookServiceAPI {
|
||||||
|
// // HookController has no public methods yet - controller access reserved for future use
|
||||||
|
// return {};
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup all controllers
|
||||||
|
*
|
||||||
|
* Should be called on session shutdown. Delegates to dispatcher's shutdown
|
||||||
|
* method to ensure all controllers are properly cleaned up.
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
// Delegate to dispatcher which manages controller cleanup
|
||||||
|
this.dispatcher.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Controller
|
||||||
|
*
|
||||||
|
* Abstract base class for domain-specific control plane controllers.
|
||||||
|
* Provides common functionality for:
|
||||||
|
* - Handling incoming control requests (SDK -> CLI)
|
||||||
|
* - Sending outgoing control requests (CLI -> SDK)
|
||||||
|
* - Request lifecycle management with timeout and cancellation
|
||||||
|
* - Integration with central pending request registry
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { IControlContext } from '../ControlContext.js';
|
||||||
|
import type {
|
||||||
|
ControlRequestPayload,
|
||||||
|
ControlResponse,
|
||||||
|
CLIControlRequest,
|
||||||
|
} from '../../types.js';
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30000; // 30 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry interface for controllers to register/deregister pending requests
|
||||||
|
*/
|
||||||
|
export interface IPendingRequestRegistry {
|
||||||
|
registerIncomingRequest(
|
||||||
|
requestId: string,
|
||||||
|
controller: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
): void;
|
||||||
|
deregisterIncomingRequest(requestId: string): void;
|
||||||
|
|
||||||
|
registerOutgoingRequest(
|
||||||
|
requestId: string,
|
||||||
|
controller: string,
|
||||||
|
resolve: (response: ControlResponse) => void,
|
||||||
|
reject: (error: Error) => void,
|
||||||
|
timeoutId: NodeJS.Timeout,
|
||||||
|
): void;
|
||||||
|
deregisterOutgoingRequest(requestId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract base controller class
|
||||||
|
*
|
||||||
|
* Subclasses should implement handleRequestPayload() to process specific
|
||||||
|
* control request types.
|
||||||
|
*/
|
||||||
|
export abstract class BaseController {
|
||||||
|
protected context: IControlContext;
|
||||||
|
protected registry: IPendingRequestRegistry;
|
||||||
|
protected controllerName: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
context: IControlContext,
|
||||||
|
registry: IPendingRequestRegistry,
|
||||||
|
controllerName: string,
|
||||||
|
) {
|
||||||
|
this.context = context;
|
||||||
|
this.registry = registry;
|
||||||
|
this.controllerName = controllerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming control request
|
||||||
|
*
|
||||||
|
* Manages lifecycle: register -> process -> deregister
|
||||||
|
*/
|
||||||
|
async handleRequest(
|
||||||
|
payload: ControlRequestPayload,
|
||||||
|
requestId: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const requestAbortController = new AbortController();
|
||||||
|
|
||||||
|
// Setup timeout
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
requestAbortController.abort();
|
||||||
|
this.registry.deregisterIncomingRequest(requestId);
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(`[${this.controllerName}] Request timeout: ${requestId}`);
|
||||||
|
}
|
||||||
|
}, DEFAULT_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Register with central registry
|
||||||
|
this.registry.registerIncomingRequest(
|
||||||
|
requestId,
|
||||||
|
this.controllerName,
|
||||||
|
requestAbortController,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.handleRequestPayload(
|
||||||
|
payload,
|
||||||
|
requestAbortController.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Success - deregister
|
||||||
|
this.registry.deregisterIncomingRequest(requestId);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// Error - deregister
|
||||||
|
this.registry.deregisterIncomingRequest(requestId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an outgoing control request to SDK
|
||||||
|
*
|
||||||
|
* Manages lifecycle: register -> send -> wait for response -> deregister
|
||||||
|
*/
|
||||||
|
async sendControlRequest(
|
||||||
|
payload: ControlRequestPayload,
|
||||||
|
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
|
): Promise<ControlResponse> {
|
||||||
|
const requestId = randomUUID();
|
||||||
|
|
||||||
|
return new Promise<ControlResponse>((resolve, reject) => {
|
||||||
|
// Setup timeout
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.registry.deregisterOutgoingRequest(requestId);
|
||||||
|
reject(new Error('Control request timeout'));
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[${this.controllerName}] Outgoing request timeout: ${requestId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
// Register with central registry
|
||||||
|
this.registry.registerOutgoingRequest(
|
||||||
|
requestId,
|
||||||
|
this.controllerName,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timeoutId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send control request
|
||||||
|
const request: CLIControlRequest = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: requestId,
|
||||||
|
request: payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.context.streamJson.send(request);
|
||||||
|
} catch (error) {
|
||||||
|
this.registry.deregisterOutgoingRequest(requestId);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract method: Handle specific request payload
|
||||||
|
*
|
||||||
|
* Subclasses must implement this to process their domain-specific requests.
|
||||||
|
*/
|
||||||
|
protected abstract handleRequestPayload(
|
||||||
|
payload: ControlRequestPayload,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
// Subclasses can override to add cleanup logic
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook Controller
|
||||||
|
*
|
||||||
|
* Handles hook-related control requests:
|
||||||
|
* - hook_callback: Process hook callbacks (placeholder for future)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseController } from './baseController.js';
|
||||||
|
import type {
|
||||||
|
ControlRequestPayload,
|
||||||
|
CLIHookCallbackRequest,
|
||||||
|
} from '../../types.js';
|
||||||
|
|
||||||
|
export class HookController extends BaseController {
|
||||||
|
/**
|
||||||
|
* Handle hook control requests
|
||||||
|
*/
|
||||||
|
protected async handleRequestPayload(
|
||||||
|
payload: ControlRequestPayload,
|
||||||
|
_signal: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
switch (payload.subtype) {
|
||||||
|
case 'hook_callback':
|
||||||
|
return this.handleHookCallback(payload as CLIHookCallbackRequest);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported request subtype in HookController`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle hook_callback request
|
||||||
|
*
|
||||||
|
* Processes hook callbacks (placeholder implementation)
|
||||||
|
*/
|
||||||
|
private async handleHookCallback(
|
||||||
|
payload: CLIHookCallbackRequest,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(`[HookController] Hook callback: ${payload.callback_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook callback processing not yet implemented
|
||||||
|
return {
|
||||||
|
result: 'Hook callback processing not yet implemented',
|
||||||
|
callback_id: payload.callback_id,
|
||||||
|
tool_use_id: payload.tool_use_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Controller
|
||||||
|
*
|
||||||
|
* Handles MCP-related control requests:
|
||||||
|
* - mcp_message: Route MCP messages
|
||||||
|
* - mcp_server_status: Return MCP server status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseController } from './baseController.js';
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { ResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type {
|
||||||
|
ControlRequestPayload,
|
||||||
|
CLIControlMcpMessageRequest,
|
||||||
|
} from '../../types.js';
|
||||||
|
import type {
|
||||||
|
MCPServerConfig,
|
||||||
|
WorkspaceContext,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
connectToMcpServer,
|
||||||
|
MCP_DEFAULT_TIMEOUT_MSEC,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
|
export class MCPController extends BaseController {
|
||||||
|
/**
|
||||||
|
* Handle MCP control requests
|
||||||
|
*/
|
||||||
|
protected async handleRequestPayload(
|
||||||
|
payload: ControlRequestPayload,
|
||||||
|
_signal: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
switch (payload.subtype) {
|
||||||
|
case 'mcp_message':
|
||||||
|
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
|
||||||
|
|
||||||
|
case 'mcp_server_status':
|
||||||
|
return this.handleMcpStatus();
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported request subtype in MCPController`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mcp_message request
|
||||||
|
*
|
||||||
|
* Routes JSON-RPC messages to MCP servers
|
||||||
|
*/
|
||||||
|
private async handleMcpMessage(
|
||||||
|
payload: CLIControlMcpMessageRequest,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const serverNameRaw = payload.server_name;
|
||||||
|
if (
|
||||||
|
typeof serverNameRaw !== 'string' ||
|
||||||
|
serverNameRaw.trim().length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('Missing server_name in mcp_message request');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = payload.message;
|
||||||
|
if (!message || typeof message !== 'object') {
|
||||||
|
throw new Error(
|
||||||
|
'Missing or invalid message payload for mcp_message request',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create MCP client
|
||||||
|
let clientEntry: { client: Client; config: MCPServerConfig };
|
||||||
|
try {
|
||||||
|
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to connect to MCP server',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = message.method;
|
||||||
|
if (typeof method !== 'string' || method.trim().length === 0) {
|
||||||
|
throw new Error('Invalid MCP message: missing method');
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonrpcVersion =
|
||||||
|
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
|
||||||
|
const messageId = message.id;
|
||||||
|
const params = message.params;
|
||||||
|
const timeout =
|
||||||
|
typeof clientEntry.config.timeout === 'number'
|
||||||
|
? clientEntry.config.timeout
|
||||||
|
: MCP_DEFAULT_TIMEOUT_MSEC;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle notification (no id)
|
||||||
|
if (messageId === undefined) {
|
||||||
|
await clientEntry.client.notification({
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
subtype: 'mcp_message',
|
||||||
|
mcp_response: {
|
||||||
|
jsonrpc: jsonrpcVersion,
|
||||||
|
id: null,
|
||||||
|
result: { success: true, acknowledged: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle request (with id)
|
||||||
|
const result = await clientEntry.client.request(
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
ResultSchema,
|
||||||
|
{ timeout },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtype: 'mcp_message',
|
||||||
|
mcp_response: {
|
||||||
|
jsonrpc: jsonrpcVersion,
|
||||||
|
id: messageId,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// If connection closed, remove from cache
|
||||||
|
if (error instanceof Error && /closed/i.test(error.message)) {
|
||||||
|
this.context.mcpClients.delete(serverNameRaw.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCode =
|
||||||
|
typeof (error as { code?: unknown })?.code === 'number'
|
||||||
|
? ((error as { code: number }).code as number)
|
||||||
|
: -32603;
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to execute MCP request';
|
||||||
|
const errorData = (error as { data?: unknown })?.data;
|
||||||
|
|
||||||
|
const errorBody: Record<string, unknown> = {
|
||||||
|
code: errorCode,
|
||||||
|
message: errorMessage,
|
||||||
|
};
|
||||||
|
if (errorData !== undefined) {
|
||||||
|
errorBody['data'] = errorData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtype: 'mcp_message',
|
||||||
|
mcp_response: {
|
||||||
|
jsonrpc: jsonrpcVersion,
|
||||||
|
id: messageId ?? null,
|
||||||
|
error: errorBody,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mcp_server_status request
|
||||||
|
*
|
||||||
|
* Returns status of registered MCP servers
|
||||||
|
*/
|
||||||
|
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||||
|
const status: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Include SDK MCP servers
|
||||||
|
for (const serverName of this.context.sdkMcpServers) {
|
||||||
|
status[serverName] = 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include CLI-managed MCP clients
|
||||||
|
for (const serverName of this.context.mcpClients.keys()) {
|
||||||
|
status[serverName] = 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create MCP client for a server
|
||||||
|
*
|
||||||
|
* Implements lazy connection and caching
|
||||||
|
*/
|
||||||
|
private async getOrCreateMcpClient(
|
||||||
|
serverName: string,
|
||||||
|
): Promise<{ client: Client; config: MCPServerConfig }> {
|
||||||
|
// Check cache first
|
||||||
|
const cached = this.context.mcpClients.get(serverName);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server configuration
|
||||||
|
const provider = this.context.config as unknown as {
|
||||||
|
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
|
||||||
|
getDebugMode?: () => boolean;
|
||||||
|
getWorkspaceContext?: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof provider.getMcpServers !== 'function') {
|
||||||
|
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = provider.getMcpServers() ?? {};
|
||||||
|
const serverConfig = servers[serverName];
|
||||||
|
if (!serverConfig) {
|
||||||
|
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const debugMode =
|
||||||
|
typeof provider.getDebugMode === 'function'
|
||||||
|
? provider.getDebugMode()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const workspaceContext =
|
||||||
|
typeof provider.getWorkspaceContext === 'function'
|
||||||
|
? provider.getWorkspaceContext()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!workspaceContext) {
|
||||||
|
throw new Error('Workspace context is not available for MCP connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to MCP server
|
||||||
|
const client = await connectToMcpServer(
|
||||||
|
serverName,
|
||||||
|
serverConfig,
|
||||||
|
debugMode,
|
||||||
|
workspaceContext as WorkspaceContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache the client
|
||||||
|
const entry = { client, config: serverConfig };
|
||||||
|
this.context.mcpClients.set(serverName, entry);
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup MCP clients
|
||||||
|
*/
|
||||||
|
override cleanup(): void {
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close all MCP clients
|
||||||
|
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
|
||||||
|
try {
|
||||||
|
client.close();
|
||||||
|
} catch (error) {
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[MCPController] Failed to close MCP client ${serverName}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.mcpClients.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission Controller
|
||||||
|
*
|
||||||
|
* Handles permission-related control requests:
|
||||||
|
* - can_use_tool: Check if tool usage is allowed
|
||||||
|
* - set_permission_mode: Change permission mode at runtime
|
||||||
|
*
|
||||||
|
* Abstracts all permission logic from the session manager to keep it clean.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ToolCallRequestInfo,
|
||||||
|
WaitingToolCall,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
InputFormat,
|
||||||
|
ToolConfirmationOutcome,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type {
|
||||||
|
CLIControlPermissionRequest,
|
||||||
|
CLIControlSetPermissionModeRequest,
|
||||||
|
ControlRequestPayload,
|
||||||
|
PermissionMode,
|
||||||
|
PermissionSuggestion,
|
||||||
|
} from '../../types.js';
|
||||||
|
import { BaseController } from './baseController.js';
|
||||||
|
|
||||||
|
// Import ToolCallConfirmationDetails types for type alignment
|
||||||
|
type ToolConfirmationType = 'edit' | 'exec' | 'mcp' | 'info' | 'plan';
|
||||||
|
|
||||||
|
export class PermissionController extends BaseController {
|
||||||
|
private pendingOutgoingRequests = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle permission control requests
|
||||||
|
*/
|
||||||
|
protected async handleRequestPayload(
|
||||||
|
payload: ControlRequestPayload,
|
||||||
|
_signal: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
switch (payload.subtype) {
|
||||||
|
case 'can_use_tool':
|
||||||
|
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
|
||||||
|
|
||||||
|
case 'set_permission_mode':
|
||||||
|
return this.handleSetPermissionMode(
|
||||||
|
payload as CLIControlSetPermissionModeRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported request subtype in PermissionController`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle can_use_tool request
|
||||||
|
*
|
||||||
|
* Comprehensive permission evaluation based on:
|
||||||
|
* - Permission mode (approval level)
|
||||||
|
* - Tool registry validation
|
||||||
|
* - Error handling with safe defaults
|
||||||
|
*/
|
||||||
|
private async handleCanUseTool(
|
||||||
|
payload: CLIControlPermissionRequest,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const toolName = payload.tool_name;
|
||||||
|
if (
|
||||||
|
!toolName ||
|
||||||
|
typeof toolName !== 'string' ||
|
||||||
|
toolName.trim().length === 0
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
subtype: 'can_use_tool',
|
||||||
|
behavior: 'deny',
|
||||||
|
message: 'Missing or invalid tool_name in can_use_tool request',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let behavior: 'allow' | 'deny' = 'allow';
|
||||||
|
let message: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check permission mode first
|
||||||
|
const permissionResult = this.checkPermissionMode();
|
||||||
|
if (!permissionResult.allowed) {
|
||||||
|
behavior = 'deny';
|
||||||
|
message = permissionResult.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tool registry if permission mode allows
|
||||||
|
if (behavior === 'allow') {
|
||||||
|
const registryResult = this.checkToolRegistry(toolName);
|
||||||
|
if (!registryResult.allowed) {
|
||||||
|
behavior = 'deny';
|
||||||
|
message = registryResult.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
behavior = 'deny';
|
||||||
|
message =
|
||||||
|
error instanceof Error
|
||||||
|
? `Failed to evaluate tool permission: ${error.message}`
|
||||||
|
: 'Failed to evaluate tool permission';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: Record<string, unknown> = {
|
||||||
|
subtype: 'can_use_tool',
|
||||||
|
behavior,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
response['message'] = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check permission mode for tool execution
|
||||||
|
*/
|
||||||
|
private checkPermissionMode(): { allowed: boolean; message?: string } {
|
||||||
|
const mode = this.context.permissionMode;
|
||||||
|
|
||||||
|
// Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES)
|
||||||
|
switch (mode) {
|
||||||
|
case 'yolo': // Allow all tools
|
||||||
|
case 'auto-edit': // Auto-approve edit operations
|
||||||
|
case 'plan': // Auto-approve planning operations
|
||||||
|
return { allowed: true };
|
||||||
|
|
||||||
|
case 'default': // TODO: allow all tools for test
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
message:
|
||||||
|
'Tool execution requires manual approval. Update permission mode or approve via host.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tool exists in registry
|
||||||
|
*/
|
||||||
|
private checkToolRegistry(toolName: string): {
|
||||||
|
allowed: boolean;
|
||||||
|
message?: string;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
// Access tool registry through config
|
||||||
|
const config = this.context.config;
|
||||||
|
const registryProvider = config as unknown as {
|
||||||
|
getToolRegistry?: () => {
|
||||||
|
getTool?: (name: string) => unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof registryProvider.getToolRegistry === 'function') {
|
||||||
|
const registry = registryProvider.getToolRegistry();
|
||||||
|
if (
|
||||||
|
registry &&
|
||||||
|
typeof registry.getTool === 'function' &&
|
||||||
|
!registry.getTool(toolName)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
message: `Tool "${toolName}" is not registered.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
message: `Failed to check tool registry: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle set_permission_mode request
|
||||||
|
*
|
||||||
|
* Updates the permission mode in the context
|
||||||
|
*/
|
||||||
|
private async handleSetPermissionMode(
|
||||||
|
payload: CLIControlSetPermissionModeRequest,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const mode = payload.mode;
|
||||||
|
const validModes: PermissionMode[] = [
|
||||||
|
'default',
|
||||||
|
'plan',
|
||||||
|
'auto-edit',
|
||||||
|
'yolo',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!validModes.includes(mode)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.permissionMode = mode;
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[PermissionController] Permission mode updated to: ${mode}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'updated', mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build permission suggestions for tool confirmation UI
|
||||||
|
*
|
||||||
|
* This method creates UI suggestions based on tool confirmation details,
|
||||||
|
* helping the host application present appropriate permission options.
|
||||||
|
*/
|
||||||
|
buildPermissionSuggestions(
|
||||||
|
confirmationDetails: unknown,
|
||||||
|
): PermissionSuggestion[] | null {
|
||||||
|
if (
|
||||||
|
!confirmationDetails ||
|
||||||
|
typeof confirmationDetails !== 'object' ||
|
||||||
|
!('type' in confirmationDetails)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = confirmationDetails as Record<string, unknown>;
|
||||||
|
const type = String(details['type'] ?? '');
|
||||||
|
const title =
|
||||||
|
typeof details['title'] === 'string' ? details['title'] : undefined;
|
||||||
|
|
||||||
|
// Ensure type matches ToolCallConfirmationDetails union
|
||||||
|
const confirmationType = type as ToolConfirmationType;
|
||||||
|
|
||||||
|
switch (confirmationType) {
|
||||||
|
case 'exec': // ToolExecuteConfirmationDetails
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'allow',
|
||||||
|
label: 'Allow Command',
|
||||||
|
description: `Execute: ${details['command']}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'deny',
|
||||||
|
label: 'Deny',
|
||||||
|
description: 'Block this command execution',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'edit': // ToolEditConfirmationDetails
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'allow',
|
||||||
|
label: 'Allow Edit',
|
||||||
|
description: `Edit file: ${details['fileName']}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'deny',
|
||||||
|
label: 'Deny',
|
||||||
|
description: 'Block this file edit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'modify',
|
||||||
|
label: 'Review Changes',
|
||||||
|
description: 'Review the proposed changes before applying',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'plan': // ToolPlanConfirmationDetails
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'allow',
|
||||||
|
label: 'Approve Plan',
|
||||||
|
description: title || 'Execute the proposed plan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'deny',
|
||||||
|
label: 'Reject Plan',
|
||||||
|
description: 'Do not execute this plan',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'mcp': // ToolMcpConfirmationDetails
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'allow',
|
||||||
|
label: 'Allow MCP Call',
|
||||||
|
description: `${details['serverName']}: ${details['toolName']}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'deny',
|
||||||
|
label: 'Deny',
|
||||||
|
description: 'Block this MCP server call',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'info': // ToolInfoConfirmationDetails
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'allow',
|
||||||
|
label: 'Allow Info Request',
|
||||||
|
description: title || 'Allow information request',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'deny',
|
||||||
|
label: 'Deny',
|
||||||
|
description: 'Block this information request',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Fallback for unknown types
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'allow',
|
||||||
|
label: 'Allow',
|
||||||
|
description: title || `Allow ${type} operation`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'deny',
|
||||||
|
label: 'Deny',
|
||||||
|
description: `Block ${type} operation`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tool should be executed based on current permission settings
|
||||||
|
*
|
||||||
|
* This is a convenience method for direct tool execution checks without
|
||||||
|
* going through the control request flow.
|
||||||
|
*/
|
||||||
|
async shouldAllowTool(
|
||||||
|
toolRequest: ToolCallRequestInfo,
|
||||||
|
confirmationDetails?: unknown,
|
||||||
|
): Promise<{
|
||||||
|
allowed: boolean;
|
||||||
|
message?: string;
|
||||||
|
updatedArgs?: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
|
// Check permission mode
|
||||||
|
const modeResult = this.checkPermissionMode();
|
||||||
|
if (!modeResult.allowed) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
message: modeResult.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tool registry
|
||||||
|
const registryResult = this.checkToolRegistry(toolRequest.name);
|
||||||
|
if (!registryResult.allowed) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
message: registryResult.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have confirmation details, we could potentially modify args
|
||||||
|
// This is a hook for future enhancement
|
||||||
|
if (confirmationDetails) {
|
||||||
|
// Future: handle argument modifications based on confirmation details
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get callback for monitoring tool calls and handling outgoing permission requests
|
||||||
|
* This is passed to executeToolCall to hook into CoreToolScheduler updates
|
||||||
|
*/
|
||||||
|
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void {
|
||||||
|
return (toolCalls: unknown[]) => {
|
||||||
|
for (const call of toolCalls) {
|
||||||
|
if (
|
||||||
|
call &&
|
||||||
|
typeof call === 'object' &&
|
||||||
|
(call as { status?: string }).status === 'awaiting_approval'
|
||||||
|
) {
|
||||||
|
const awaiting = call as WaitingToolCall;
|
||||||
|
if (
|
||||||
|
typeof awaiting.confirmationDetails?.onConfirm === 'function' &&
|
||||||
|
!this.pendingOutgoingRequests.has(awaiting.request.callId)
|
||||||
|
) {
|
||||||
|
this.pendingOutgoingRequests.add(awaiting.request.callId);
|
||||||
|
void this.handleOutgoingPermissionRequest(awaiting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle outgoing permission request
|
||||||
|
*
|
||||||
|
* Behavior depends on input format:
|
||||||
|
* - stream-json mode: Send can_use_tool to SDK and await response
|
||||||
|
* - Other modes: Check local approval mode and decide immediately
|
||||||
|
*/
|
||||||
|
private async handleOutgoingPermissionRequest(
|
||||||
|
toolCall: WaitingToolCall,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const inputFormat = this.context.config.getInputFormat?.();
|
||||||
|
const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON;
|
||||||
|
|
||||||
|
if (!isStreamJsonMode) {
|
||||||
|
// No SDK available - use local permission check
|
||||||
|
const modeCheck = this.checkPermissionMode();
|
||||||
|
const outcome = modeCheck.allowed
|
||||||
|
? ToolConfirmationOutcome.ProceedOnce
|
||||||
|
: ToolConfirmationOutcome.Cancel;
|
||||||
|
|
||||||
|
await toolCall.confirmationDetails.onConfirm(outcome);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream-json mode: ask SDK for permission
|
||||||
|
const permissionSuggestions = this.buildPermissionSuggestions(
|
||||||
|
toolCall.confirmationDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await this.sendControlRequest(
|
||||||
|
{
|
||||||
|
subtype: 'can_use_tool',
|
||||||
|
tool_name: toolCall.request.name,
|
||||||
|
tool_use_id: toolCall.request.callId,
|
||||||
|
input: toolCall.request.args,
|
||||||
|
permission_suggestions: permissionSuggestions,
|
||||||
|
blocked_path: null,
|
||||||
|
} as CLIControlPermissionRequest,
|
||||||
|
30000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.subtype !== 'success') {
|
||||||
|
await toolCall.confirmationDetails.onConfirm(
|
||||||
|
ToolConfirmationOutcome.Cancel,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (response.response || {}) as Record<string, unknown>;
|
||||||
|
const behavior = String(payload['behavior'] || '').toLowerCase();
|
||||||
|
|
||||||
|
if (behavior === 'allow') {
|
||||||
|
// Handle updated input if provided
|
||||||
|
const updatedInput = payload['updatedInput'];
|
||||||
|
if (updatedInput && typeof updatedInput === 'object') {
|
||||||
|
toolCall.request.args = updatedInput as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
await toolCall.confirmationDetails.onConfirm(
|
||||||
|
ToolConfirmationOutcome.ProceedOnce,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await toolCall.confirmationDetails.onConfirm(
|
||||||
|
ToolConfirmationOutcome.Cancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[PermissionController] Outgoing permission failed:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await toolCall.confirmationDetails.onConfirm(
|
||||||
|
ToolConfirmationOutcome.Cancel,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.pendingOutgoingRequests.delete(toolCall.request.callId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System Controller
|
||||||
|
*
|
||||||
|
* Handles system-level control requests:
|
||||||
|
* - initialize: Setup session and return system info
|
||||||
|
* - interrupt: Cancel current operations
|
||||||
|
* - set_model: Switch model (placeholder)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseController } from './baseController.js';
|
||||||
|
import type {
|
||||||
|
ControlRequestPayload,
|
||||||
|
CLIControlInitializeRequest,
|
||||||
|
CLIControlSetModelRequest,
|
||||||
|
} from '../../types.js';
|
||||||
|
|
||||||
|
export class SystemController extends BaseController {
|
||||||
|
/**
|
||||||
|
* Handle system control requests
|
||||||
|
*/
|
||||||
|
protected async handleRequestPayload(
|
||||||
|
payload: ControlRequestPayload,
|
||||||
|
_signal: AbortSignal,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
switch (payload.subtype) {
|
||||||
|
case 'initialize':
|
||||||
|
return this.handleInitialize(payload as CLIControlInitializeRequest);
|
||||||
|
|
||||||
|
case 'interrupt':
|
||||||
|
return this.handleInterrupt();
|
||||||
|
|
||||||
|
case 'set_model':
|
||||||
|
return this.handleSetModel(payload as CLIControlSetModelRequest);
|
||||||
|
|
||||||
|
case 'supported_commands':
|
||||||
|
return this.handleSupportedCommands();
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported request subtype in SystemController`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle initialize request
|
||||||
|
*
|
||||||
|
* Registers SDK MCP servers and returns capabilities
|
||||||
|
*/
|
||||||
|
private async handleInitialize(
|
||||||
|
payload: CLIControlInitializeRequest,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
// Register SDK MCP servers if provided
|
||||||
|
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
|
||||||
|
for (const serverName of payload.sdkMcpServers) {
|
||||||
|
this.context.sdkMcpServers.add(serverName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build capabilities for response
|
||||||
|
const capabilities = this.buildControlCapabilities();
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtype: 'initialize',
|
||||||
|
capabilities,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build control capabilities for initialize control response
|
||||||
|
*
|
||||||
|
* This method constructs the control capabilities object that indicates
|
||||||
|
* what control features are available. It is used exclusively in the
|
||||||
|
* initialize control response.
|
||||||
|
*/
|
||||||
|
buildControlCapabilities(): Record<string, unknown> {
|
||||||
|
const capabilities: Record<string, unknown> = {
|
||||||
|
can_handle_can_use_tool: true,
|
||||||
|
can_handle_hook_callback: true,
|
||||||
|
can_set_permission_mode:
|
||||||
|
typeof this.context.config.setApprovalMode === 'function',
|
||||||
|
can_set_model: typeof this.context.config.setModel === 'function',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if MCP message handling is available
|
||||||
|
try {
|
||||||
|
const mcpProvider = this.context.config as unknown as {
|
||||||
|
getMcpServers?: () => Record<string, unknown> | undefined;
|
||||||
|
};
|
||||||
|
if (typeof mcpProvider.getMcpServers === 'function') {
|
||||||
|
const servers = mcpProvider.getMcpServers();
|
||||||
|
capabilities['can_handle_mcp_message'] = Boolean(
|
||||||
|
servers && Object.keys(servers).length > 0,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
capabilities['can_handle_mcp_message'] = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SystemController] Failed to determine MCP capability:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
capabilities['can_handle_mcp_message'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle interrupt request
|
||||||
|
*
|
||||||
|
* Triggers the interrupt callback to cancel current operations
|
||||||
|
*/
|
||||||
|
private async handleInterrupt(): Promise<Record<string, unknown>> {
|
||||||
|
// Trigger interrupt callback if available
|
||||||
|
if (this.context.onInterrupt) {
|
||||||
|
this.context.onInterrupt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort the main signal to cancel ongoing operations
|
||||||
|
if (this.context.abortSignal && !this.context.abortSignal.aborted) {
|
||||||
|
// Note: We can't directly abort the signal, but the onInterrupt callback should handle this
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error('[SystemController] Interrupt signal triggered');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error('[SystemController] Interrupt handled');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subtype: 'interrupt' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle set_model request
|
||||||
|
*
|
||||||
|
* Implements actual model switching with validation and error handling
|
||||||
|
*/
|
||||||
|
private async handleSetModel(
|
||||||
|
payload: CLIControlSetModelRequest,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const model = payload.model;
|
||||||
|
|
||||||
|
// Validate model parameter
|
||||||
|
if (typeof model !== 'string' || model.trim() === '') {
|
||||||
|
throw new Error('Invalid model specified for set_model request');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt to set the model using config
|
||||||
|
await this.context.config.setModel(model);
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(`[SystemController] Model switched to: ${model}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtype: 'set_model',
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : 'Failed to set model';
|
||||||
|
|
||||||
|
if (this.context.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[SystemController] Failed to set model ${model}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle supported_commands request
|
||||||
|
*
|
||||||
|
* Returns list of supported control commands
|
||||||
|
*
|
||||||
|
* Note: This list should match the ControlRequestType enum in
|
||||||
|
* packages/sdk/typescript/src/types/controlRequests.ts
|
||||||
|
*/
|
||||||
|
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
|
||||||
|
const commands = [
|
||||||
|
'initialize',
|
||||||
|
'interrupt',
|
||||||
|
'set_model',
|
||||||
|
'supported_commands',
|
||||||
|
'can_use_tool',
|
||||||
|
'set_permission_mode',
|
||||||
|
'mcp_message',
|
||||||
|
'mcp_server_status',
|
||||||
|
'hook_callback',
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtype: 'supported_commands',
|
||||||
|
commands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
139
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts
Normal file
139
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service API Types
|
||||||
|
*
|
||||||
|
* These interfaces define the public API contract for the ControlService facade.
|
||||||
|
* They provide type-safe, domain-grouped access to control plane functionality
|
||||||
|
* for internal CLI code (nonInteractiveCli, session managers, etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import type {
|
||||||
|
ToolCallRequestInfo,
|
||||||
|
MCPServerConfig,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type { PermissionSuggestion } from '../../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission Service API
|
||||||
|
*
|
||||||
|
* Provides permission-related operations including tool execution approval,
|
||||||
|
* permission suggestions, and tool call monitoring callbacks.
|
||||||
|
*/
|
||||||
|
export interface PermissionServiceAPI {
|
||||||
|
/**
|
||||||
|
* Check if a tool should be allowed based on current permission settings
|
||||||
|
*
|
||||||
|
* Evaluates permission mode and tool registry to determine if execution
|
||||||
|
* should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||||
|
*
|
||||||
|
* @param toolRequest - Tool call request information containing name, args, and call ID
|
||||||
|
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
|
||||||
|
* @returns Promise resolving to permission decision with optional updated arguments
|
||||||
|
*/
|
||||||
|
shouldAllowTool(
|
||||||
|
toolRequest: ToolCallRequestInfo,
|
||||||
|
confirmationDetails?: unknown,
|
||||||
|
): Promise<{
|
||||||
|
allowed: boolean;
|
||||||
|
message?: string;
|
||||||
|
updatedArgs?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build UI suggestions for tool confirmation dialogs
|
||||||
|
*
|
||||||
|
* Creates actionable permission suggestions based on tool confirmation details,
|
||||||
|
* helping host applications present appropriate approval/denial options.
|
||||||
|
*
|
||||||
|
* @param confirmationDetails - Tool confirmation details (type, title, metadata)
|
||||||
|
* @returns Array of permission suggestions or null if details are invalid
|
||||||
|
*/
|
||||||
|
buildPermissionSuggestions(
|
||||||
|
confirmationDetails: unknown,
|
||||||
|
): PermissionSuggestion[] | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get callback for monitoring tool call status updates
|
||||||
|
*
|
||||||
|
* Returns a callback function that should be passed to executeToolCall
|
||||||
|
* to enable integration with CoreToolScheduler updates. This callback
|
||||||
|
* handles outgoing permission requests for tools awaiting approval.
|
||||||
|
*
|
||||||
|
* @returns Callback function that processes tool call updates
|
||||||
|
*/
|
||||||
|
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System Service API
|
||||||
|
*
|
||||||
|
* Provides system-level operations for the control system.
|
||||||
|
*
|
||||||
|
* Note: System messages and slash commands are NOT part of the control system API.
|
||||||
|
* They are handled independently via buildSystemMessage() from nonInteractiveHelpers.ts,
|
||||||
|
* regardless of whether the control system is available.
|
||||||
|
*/
|
||||||
|
export interface SystemServiceAPI {
|
||||||
|
/**
|
||||||
|
* Get control capabilities
|
||||||
|
*
|
||||||
|
* Returns the control capabilities object indicating what control
|
||||||
|
* features are available. Used exclusively for the initialize control
|
||||||
|
* response. System messages do not include capabilities as they are
|
||||||
|
* independent of the control system.
|
||||||
|
*
|
||||||
|
* @returns Control capabilities object
|
||||||
|
*/
|
||||||
|
getControlCapabilities(): Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Service API
|
||||||
|
*
|
||||||
|
* Provides Model Context Protocol server interaction including
|
||||||
|
* lazy client initialization and server discovery.
|
||||||
|
*/
|
||||||
|
export interface McpServiceAPI {
|
||||||
|
/**
|
||||||
|
* Get or create MCP client for a server (lazy initialization)
|
||||||
|
*
|
||||||
|
* Returns an existing client from cache or creates a new connection
|
||||||
|
* if this is the first request for the server. Handles connection
|
||||||
|
* lifecycle and error recovery.
|
||||||
|
*
|
||||||
|
* @param serverName - Name of the MCP server to connect to
|
||||||
|
* @returns Promise resolving to client instance and server configuration
|
||||||
|
* @throws Error if server is not configured or connection fails
|
||||||
|
*/
|
||||||
|
getMcpClient(serverName: string): Promise<{
|
||||||
|
client: Client;
|
||||||
|
config: MCPServerConfig;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available MCP servers
|
||||||
|
*
|
||||||
|
* Returns names of both SDK-managed and CLI-managed MCP servers
|
||||||
|
* that are currently configured or connected.
|
||||||
|
*
|
||||||
|
* @returns Array of server names
|
||||||
|
*/
|
||||||
|
listServers(): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook Service API
|
||||||
|
*
|
||||||
|
* Provides hook callback processing (placeholder for future expansion).
|
||||||
|
*/
|
||||||
|
export interface HookServiceAPI {
|
||||||
|
// Future: Hook-related methods will be added here
|
||||||
|
// For now, hook functionality is handled only via control requests
|
||||||
|
registerHookCallback(callback: unknown): void;
|
||||||
|
}
|
||||||
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
@@ -0,0 +1,791 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import type {
|
||||||
|
Config,
|
||||||
|
ServerGeminiStreamEvent,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { Part } from '@google/genai';
|
||||||
|
import { JsonOutputAdapter } from './JsonOutputAdapter.js';
|
||||||
|
|
||||||
|
function createMockConfig(): Config {
|
||||||
|
return {
|
||||||
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||||
|
getModel: vi.fn().mockReturnValue('test-model'),
|
||||||
|
} as unknown as Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JsonOutputAdapter', () => {
|
||||||
|
let adapter: JsonOutputAdapter;
|
||||||
|
let mockConfig: Config;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let stdoutWriteSpy: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig = createMockConfig();
|
||||||
|
adapter = new JsonOutputAdapter(mockConfig);
|
||||||
|
stdoutWriteSpy = vi
|
||||||
|
.spyOn(process.stdout, 'write')
|
||||||
|
.mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stdoutWriteSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startAssistantMessage', () => {
|
||||||
|
it('should reset state for new message', () => {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.startAssistantMessage(); // Start second message
|
||||||
|
// Should not throw
|
||||||
|
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processEvent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append text content from Content events', () => {
|
||||||
|
const event: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Hello',
|
||||||
|
};
|
||||||
|
adapter.processEvent(event);
|
||||||
|
|
||||||
|
const event2: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: ' World',
|
||||||
|
};
|
||||||
|
adapter.processEvent(event2);
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
text: 'Hello World',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append citation content from Citation events', () => {
|
||||||
|
const event: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Citation,
|
||||||
|
value: 'Citation text',
|
||||||
|
};
|
||||||
|
adapter.processEvent(event);
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
text: expect.stringContaining('Citation text'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore non-string citation values', () => {
|
||||||
|
const event: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Citation,
|
||||||
|
value: 123,
|
||||||
|
} as unknown as ServerGeminiStreamEvent;
|
||||||
|
adapter.processEvent(event);
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append thinking from Thought events', () => {
|
||||||
|
const event: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: 'Planning',
|
||||||
|
description: 'Thinking about the task',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
adapter.processEvent(event);
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'Planning: Thinking about the task',
|
||||||
|
signature: 'Planning',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle thinking with only subject', () => {
|
||||||
|
const event: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: 'Planning',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
adapter.processEvent(event);
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'thinking',
|
||||||
|
signature: 'Planning',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append tool use from ToolCallRequest events', () => {
|
||||||
|
const event: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-call-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
args: { param1: 'value1' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
adapter.processEvent(event);
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-call-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
input: { param1: 'value1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-call-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
args: { param1: 'value1' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.stop_reason).toBe('tool_use');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stop_reason to null when message contains text blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Some text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.stop_reason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stop_reason to null when message contains thinking blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: 'Planning',
|
||||||
|
description: 'Thinking about the task',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.stop_reason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-call-1',
|
||||||
|
name: 'test_tool_1',
|
||||||
|
args: { param1: 'value1' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-call-2',
|
||||||
|
name: 'test_tool_2',
|
||||||
|
args: { param2: 'value2' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
message.message.content.every((block) => block.type === 'tool_use'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(message.message.stop_reason).toBe('tool_use');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update usage from Finished event', () => {
|
||||||
|
const usageMetadata = {
|
||||||
|
promptTokenCount: 100,
|
||||||
|
candidatesTokenCount: 50,
|
||||||
|
cachedContentTokenCount: 10,
|
||||||
|
totalTokenCount: 160,
|
||||||
|
};
|
||||||
|
const event: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: {
|
||||||
|
reason: undefined,
|
||||||
|
usageMetadata,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
adapter.processEvent(event);
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.usage).toMatchObject({
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 50,
|
||||||
|
cache_read_input_tokens: 10,
|
||||||
|
total_tokens: 160,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should finalize pending blocks on Finished event', () => {
|
||||||
|
// Add some text first
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Some text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const event: ServerGeminiStreamEvent = {
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: { reason: undefined, usageMetadata: undefined },
|
||||||
|
};
|
||||||
|
adapter.processEvent(event);
|
||||||
|
|
||||||
|
// Should not throw when finalizing
|
||||||
|
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore events after finalization', () => {
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
const originalContent =
|
||||||
|
adapter.finalizeAssistantMessage().message.content;
|
||||||
|
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Should be ignored',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toEqual(originalContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('finalizeAssistantMessage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build and emit a complete assistant message', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Test response',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
expect(message.type).toBe('assistant');
|
||||||
|
expect(message.uuid).toBeTruthy();
|
||||||
|
expect(message.session_id).toBe('test-session-id');
|
||||||
|
expect(message.parent_tool_use_id).toBeNull();
|
||||||
|
expect(message.message.role).toBe('assistant');
|
||||||
|
expect(message.message.model).toBe('test-model');
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return same message on subsequent calls', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message1 = adapter.finalizeAssistantMessage();
|
||||||
|
const message2 = adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
expect(message1).toEqual(message2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split different block types into separate assistant messages', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text',
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: { subject: 'Thinking', description: 'Thought' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0].type).toBe('thinking');
|
||||||
|
|
||||||
|
const storedMessages = (adapter as unknown as { messages: unknown[] })
|
||||||
|
.messages;
|
||||||
|
const assistantMessages = storedMessages.filter(
|
||||||
|
(
|
||||||
|
msg,
|
||||||
|
): msg is {
|
||||||
|
type: string;
|
||||||
|
message: { content: Array<{ type: string }> };
|
||||||
|
} => {
|
||||||
|
if (
|
||||||
|
typeof msg !== 'object' ||
|
||||||
|
msg === null ||
|
||||||
|
!('type' in msg) ||
|
||||||
|
(msg as { type?: string }).type !== 'assistant' ||
|
||||||
|
!('message' in msg)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const message = (msg as { message?: unknown }).message;
|
||||||
|
return (
|
||||||
|
typeof message === 'object' &&
|
||||||
|
message !== null &&
|
||||||
|
'content' in message &&
|
||||||
|
Array.isArray((message as { content?: unknown }).content)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assistantMessages).toHaveLength(2);
|
||||||
|
for (const assistant of assistantMessages) {
|
||||||
|
const uniqueTypes = new Set(
|
||||||
|
assistant.message.content.map((block) => block.type),
|
||||||
|
);
|
||||||
|
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if message not started', () => {
|
||||||
|
adapter = new JsonOutputAdapter(mockConfig);
|
||||||
|
expect(() => adapter.finalizeAssistantMessage()).toThrow(
|
||||||
|
'Message not started',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitResult', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Response text',
|
||||||
|
});
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit success result as JSON array', () => {
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(Array.isArray(parsed)).toBe(true);
|
||||||
|
const resultMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultMessage).toBeDefined();
|
||||||
|
expect(resultMessage.is_error).toBe(false);
|
||||||
|
expect(resultMessage.subtype).toBe('success');
|
||||||
|
expect(resultMessage.result).toBe('Response text');
|
||||||
|
expect(resultMessage.duration_ms).toBe(1000);
|
||||||
|
expect(resultMessage.num_turns).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit error result', () => {
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: true,
|
||||||
|
errorMessage: 'Test error',
|
||||||
|
durationMs: 500,
|
||||||
|
apiDurationMs: 300,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const resultMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultMessage.is_error).toBe(true);
|
||||||
|
expect(resultMessage.subtype).toBe('error_during_execution');
|
||||||
|
expect(resultMessage.error?.message).toBe('Test error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use provided summary over extracted text', () => {
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
summary: 'Custom summary',
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const resultMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultMessage.result).toBe('Custom summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include usage information', () => {
|
||||||
|
const usage = {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 50,
|
||||||
|
total_tokens: 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
usage,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const resultMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultMessage.usage).toEqual(usage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include stats when provided', () => {
|
||||||
|
const stats = {
|
||||||
|
models: {},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 5,
|
||||||
|
totalSuccess: 4,
|
||||||
|
totalFail: 1,
|
||||||
|
totalDurationMs: 1000,
|
||||||
|
totalDecisions: {
|
||||||
|
accept: 3,
|
||||||
|
reject: 1,
|
||||||
|
modify: 0,
|
||||||
|
auto_accept: 1,
|
||||||
|
},
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
totalLinesAdded: 10,
|
||||||
|
totalLinesRemoved: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
stats,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const resultMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resultMessage.stats).toEqual(stats);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitUserMessage', () => {
|
||||||
|
it('should add user message to collection', () => {
|
||||||
|
const parts: Part[] = [{ text: 'Hello user' }];
|
||||||
|
adapter.emitUserMessage(parts);
|
||||||
|
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const userMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'user',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(userMessage).toBeDefined();
|
||||||
|
expect(Array.isArray(userMessage.message.content)).toBe(true);
|
||||||
|
if (Array.isArray(userMessage.message.content)) {
|
||||||
|
expect(userMessage.message.content).toHaveLength(1);
|
||||||
|
expect(userMessage.message.content[0]).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
text: 'Hello user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle parent_tool_use_id', () => {
|
||||||
|
const parts: Part[] = [{ text: 'Tool response' }];
|
||||||
|
adapter.emitUserMessage(parts);
|
||||||
|
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const userMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'user',
|
||||||
|
);
|
||||||
|
|
||||||
|
// emitUserMessage currently sets parent_tool_use_id to null
|
||||||
|
expect(userMessage.parent_tool_use_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitToolResult', () => {
|
||||||
|
it('should emit tool result message', () => {
|
||||||
|
const request = {
|
||||||
|
callId: 'tool-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
args: {},
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
};
|
||||||
|
const response = {
|
||||||
|
callId: 'tool-1',
|
||||||
|
responseParts: [],
|
||||||
|
resultDisplay: 'Tool executed successfully',
|
||||||
|
error: undefined,
|
||||||
|
errorType: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.emitToolResult(request, response);
|
||||||
|
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const toolResult = parsed.find(
|
||||||
|
(
|
||||||
|
msg: unknown,
|
||||||
|
): msg is { type: 'user'; message: { content: unknown[] } } =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'user' &&
|
||||||
|
'message' in msg &&
|
||||||
|
typeof msg.message === 'object' &&
|
||||||
|
msg.message !== null &&
|
||||||
|
'content' in msg.message &&
|
||||||
|
Array.isArray(msg.message.content) &&
|
||||||
|
msg.message.content[0] &&
|
||||||
|
typeof msg.message.content[0] === 'object' &&
|
||||||
|
'type' in msg.message.content[0] &&
|
||||||
|
msg.message.content[0].type === 'tool_result',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(toolResult).toBeDefined();
|
||||||
|
const block = toolResult.message.content[0] as {
|
||||||
|
type: 'tool_result';
|
||||||
|
tool_use_id: string;
|
||||||
|
content?: string;
|
||||||
|
is_error?: boolean;
|
||||||
|
};
|
||||||
|
expect(block).toMatchObject({
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-1',
|
||||||
|
content: 'Tool executed successfully',
|
||||||
|
is_error: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark error tool results', () => {
|
||||||
|
const request = {
|
||||||
|
callId: 'tool-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
args: {},
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
};
|
||||||
|
const response = {
|
||||||
|
callId: 'tool-1',
|
||||||
|
responseParts: [],
|
||||||
|
resultDisplay: undefined,
|
||||||
|
error: new Error('Tool failed'),
|
||||||
|
errorType: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.emitToolResult(request, response);
|
||||||
|
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const toolResult = parsed.find(
|
||||||
|
(
|
||||||
|
msg: unknown,
|
||||||
|
): msg is { type: 'user'; message: { content: unknown[] } } =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'user' &&
|
||||||
|
'message' in msg &&
|
||||||
|
typeof msg.message === 'object' &&
|
||||||
|
msg.message !== null &&
|
||||||
|
'content' in msg.message &&
|
||||||
|
Array.isArray(msg.message.content),
|
||||||
|
);
|
||||||
|
|
||||||
|
const block = toolResult.message.content[0] as {
|
||||||
|
is_error?: boolean;
|
||||||
|
};
|
||||||
|
expect(block.is_error).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitSystemMessage', () => {
|
||||||
|
it('should add system message to collection', () => {
|
||||||
|
adapter.emitSystemMessage('test_subtype', { data: 'value' });
|
||||||
|
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
const systemMessage = parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
msg.type === 'system',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(systemMessage).toBeDefined();
|
||||||
|
expect(systemMessage.subtype).toBe('test_subtype');
|
||||||
|
expect(systemMessage.data).toEqual({ data: 'value' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSessionId and getModel', () => {
|
||||||
|
it('should return session ID from config', () => {
|
||||||
|
expect(adapter.getSessionId()).toBe('test-session-id');
|
||||||
|
expect(mockConfig.getSessionId).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return model from config', () => {
|
||||||
|
expect(adapter.getModel()).toBe('test-model');
|
||||||
|
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple messages in collection', () => {
|
||||||
|
it('should collect all messages and emit as array', () => {
|
||||||
|
adapter.emitSystemMessage('init', {});
|
||||||
|
adapter.emitUserMessage([{ text: 'User input' }]);
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Assistant response',
|
||||||
|
});
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(Array.isArray(parsed)).toBe(true);
|
||||||
|
expect(parsed.length).toBeGreaterThanOrEqual(3);
|
||||||
|
const systemMsg = parsed[0] as { type?: string };
|
||||||
|
const userMsg = parsed[1] as { type?: string };
|
||||||
|
expect(systemMsg.type).toBe('system');
|
||||||
|
expect(userMsg.type).toBe('user');
|
||||||
|
expect(
|
||||||
|
parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
(msg as { type?: string }).type === 'assistant',
|
||||||
|
),
|
||||||
|
).toBeDefined();
|
||||||
|
expect(
|
||||||
|
parsed.find(
|
||||||
|
(msg: unknown) =>
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg !== null &&
|
||||||
|
'type' in msg &&
|
||||||
|
(msg as { type?: string }).type === 'result',
|
||||||
|
),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { CLIAssistantMessage, CLIMessage } from '../types.js';
|
||||||
|
import {
|
||||||
|
BaseJsonOutputAdapter,
|
||||||
|
type JsonOutputAdapterInterface,
|
||||||
|
type ResultOptions,
|
||||||
|
} from './BaseJsonOutputAdapter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON output adapter that collects all messages and emits them
|
||||||
|
* as a single JSON array at the end of the turn.
|
||||||
|
* Supports both main agent and subagent messages through distinct APIs.
|
||||||
|
*/
|
||||||
|
export class JsonOutputAdapter
|
||||||
|
extends BaseJsonOutputAdapter
|
||||||
|
implements JsonOutputAdapterInterface
|
||||||
|
{
|
||||||
|
private readonly messages: CLIMessage[] = [];
|
||||||
|
|
||||||
|
constructor(config: Config) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits message to the messages array (batch mode).
|
||||||
|
* Tracks the last assistant message for efficient result text extraction.
|
||||||
|
*/
|
||||||
|
protected emitMessageImpl(message: CLIMessage): void {
|
||||||
|
this.messages.push(message);
|
||||||
|
// Track assistant messages for result generation
|
||||||
|
if (
|
||||||
|
typeof message === 'object' &&
|
||||||
|
message !== null &&
|
||||||
|
'type' in message &&
|
||||||
|
message.type === 'assistant'
|
||||||
|
) {
|
||||||
|
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON mode does not emit stream events.
|
||||||
|
*/
|
||||||
|
protected shouldEmitStreamEvents(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||||
|
const message = this.finalizeAssistantMessageInternal(
|
||||||
|
this.mainAgentMessageState,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
this.updateLastAssistantMessage(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitResult(options: ResultOptions): void {
|
||||||
|
const resultMessage = this.buildResultMessage(
|
||||||
|
options,
|
||||||
|
this.lastAssistantMessage,
|
||||||
|
);
|
||||||
|
this.messages.push(resultMessage);
|
||||||
|
|
||||||
|
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
|
||||||
|
const json = JSON.stringify(this.messages);
|
||||||
|
process.stdout.write(`${json}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitMessage(message: CLIMessage): void {
|
||||||
|
// In JSON mode, messages are collected in the messages array
|
||||||
|
// This is called by the base class's finalizeAssistantMessageInternal
|
||||||
|
// but can also be called directly for user/tool/system messages
|
||||||
|
this.messages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
StreamJsonInputReader,
|
||||||
|
StreamJsonParseError,
|
||||||
|
type StreamJsonInputMessage,
|
||||||
|
} from './StreamJsonInputReader.js';
|
||||||
|
|
||||||
|
describe('StreamJsonInputReader', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('read', () => {
|
||||||
|
/**
|
||||||
|
* Test parsing all supported message types in a single test
|
||||||
|
*/
|
||||||
|
it('should parse valid messages of all types', async () => {
|
||||||
|
const input = new PassThrough();
|
||||||
|
const reader = new StreamJsonInputReader(input);
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: 'user',
|
||||||
|
session_id: 'test-session',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: 'hello world' }],
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-1',
|
||||||
|
request: { subtype: 'initialize' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: 'req-1',
|
||||||
|
response: { initialized: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'control_cancel_request',
|
||||||
|
request_id: 'req-1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
input.write(JSON.stringify(msg) + '\n');
|
||||||
|
}
|
||||||
|
input.end();
|
||||||
|
|
||||||
|
const parsed: StreamJsonInputMessage[] = [];
|
||||||
|
for await (const msg of reader.read()) {
|
||||||
|
parsed.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(parsed).toHaveLength(messages.length);
|
||||||
|
expect(parsed).toEqual(messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse multiple messages', async () => {
|
||||||
|
const input = new PassThrough();
|
||||||
|
const reader = new StreamJsonInputReader(input);
|
||||||
|
|
||||||
|
const message1 = {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-1',
|
||||||
|
request: { subtype: 'initialize' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const message2 = {
|
||||||
|
type: 'user',
|
||||||
|
session_id: 'test-session',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
input.write(JSON.stringify(message1) + '\n');
|
||||||
|
input.write(JSON.stringify(message2) + '\n');
|
||||||
|
input.end();
|
||||||
|
|
||||||
|
const messages: StreamJsonInputMessage[] = [];
|
||||||
|
for await (const msg of reader.read()) {
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(messages[0]).toEqual(message1);
|
||||||
|
expect(messages[1]).toEqual(message2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip empty lines and trim whitespace', async () => {
|
||||||
|
const input = new PassThrough();
|
||||||
|
const reader = new StreamJsonInputReader(input);
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'user',
|
||||||
|
session_id: 'test-session',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
input.write('\n');
|
||||||
|
input.write(' ' + JSON.stringify(message) + ' \n');
|
||||||
|
input.write(' \n');
|
||||||
|
input.write('\t\n');
|
||||||
|
input.end();
|
||||||
|
|
||||||
|
const messages: StreamJsonInputMessage[] = [];
|
||||||
|
for await (const msg of reader.read()) {
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0]).toEqual(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidated error handling test cases
|
||||||
|
*/
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'invalid JSON',
|
||||||
|
input: '{"invalid": json}\n',
|
||||||
|
expectedError: 'Failed to parse stream-json line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'missing type field',
|
||||||
|
input:
|
||||||
|
JSON.stringify({ session_id: 'test-session', message: 'hello' }) +
|
||||||
|
'\n',
|
||||||
|
expectedError: 'Missing required "type" field',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-object value (string)',
|
||||||
|
input: '"just a string"\n',
|
||||||
|
expectedError: 'Parsed value is not an object',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-object value (null)',
|
||||||
|
input: 'null\n',
|
||||||
|
expectedError: 'Parsed value is not an object',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'array value',
|
||||||
|
input: '[1, 2, 3]\n',
|
||||||
|
expectedError: 'Missing required "type" field',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type field not a string',
|
||||||
|
input: JSON.stringify({ type: 123, session_id: 'test-session' }) + '\n',
|
||||||
|
expectedError: 'Missing required "type" field',
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should throw StreamJsonParseError for $name',
|
||||||
|
async ({ input: inputLine, expectedError }) => {
|
||||||
|
const input = new PassThrough();
|
||||||
|
const reader = new StreamJsonInputReader(input);
|
||||||
|
|
||||||
|
input.write(inputLine);
|
||||||
|
input.end();
|
||||||
|
|
||||||
|
const messages: StreamJsonInputMessage[] = [];
|
||||||
|
let error: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const msg of reader.read()) {
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(messages).toHaveLength(0);
|
||||||
|
expect(error).toBeInstanceOf(StreamJsonParseError);
|
||||||
|
expect((error as StreamJsonParseError).message).toContain(
|
||||||
|
expectedError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should use process.stdin as default input', () => {
|
||||||
|
const reader = new StreamJsonInputReader();
|
||||||
|
// Access private field for testing constructor default parameter
|
||||||
|
expect((reader as unknown as { input: typeof process.stdin }).input).toBe(
|
||||||
|
process.stdin,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use provided input stream', () => {
|
||||||
|
const customInput = new PassThrough();
|
||||||
|
const reader = new StreamJsonInputReader(customInput);
|
||||||
|
// Access private field for testing constructor parameter
|
||||||
|
expect((reader as unknown as { input: typeof customInput }).input).toBe(
|
||||||
|
customInput,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createInterface } from 'node:readline/promises';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
import process from 'node:process';
|
||||||
|
import type {
|
||||||
|
CLIControlRequest,
|
||||||
|
CLIControlResponse,
|
||||||
|
CLIMessage,
|
||||||
|
ControlCancelRequest,
|
||||||
|
} from '../types.js';
|
||||||
|
|
||||||
|
export type StreamJsonInputMessage =
|
||||||
|
| CLIMessage
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest;
|
||||||
|
|
||||||
|
export class StreamJsonParseError extends Error {}
|
||||||
|
|
||||||
|
export class StreamJsonInputReader {
|
||||||
|
private readonly input: Readable;
|
||||||
|
|
||||||
|
constructor(input: Readable = process.stdin) {
|
||||||
|
this.input = input;
|
||||||
|
}
|
||||||
|
|
||||||
|
async *read(): AsyncGenerator<StreamJsonInputMessage> {
|
||||||
|
const rl = createInterface({
|
||||||
|
input: this.input,
|
||||||
|
crlfDelay: Number.POSITIVE_INFINITY,
|
||||||
|
terminal: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const rawLine of rl) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield this.parse(line);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parse(line: string): StreamJsonInputMessage {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line) as StreamJsonInputMessage;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
throw new StreamJsonParseError('Parsed value is not an object');
|
||||||
|
}
|
||||||
|
if (!('type' in parsed) || typeof parsed.type !== 'string') {
|
||||||
|
throw new StreamJsonParseError('Missing required "type" field');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof StreamJsonParseError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const reason = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new StreamJsonParseError(
|
||||||
|
`Failed to parse stream-json line: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,997 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import type {
|
||||||
|
Config,
|
||||||
|
ServerGeminiStreamEvent,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||||
|
import type { Part } from '@google/genai';
|
||||||
|
import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js';
|
||||||
|
|
||||||
|
function createMockConfig(): Config {
|
||||||
|
return {
|
||||||
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||||
|
getModel: vi.fn().mockReturnValue('test-model'),
|
||||||
|
} as unknown as Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StreamJsonOutputAdapter', () => {
|
||||||
|
let adapter: StreamJsonOutputAdapter;
|
||||||
|
let mockConfig: Config;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let stdoutWriteSpy: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig = createMockConfig();
|
||||||
|
stdoutWriteSpy = vi
|
||||||
|
.spyOn(process.stdout, 'write')
|
||||||
|
.mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stdoutWriteSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with partial messages enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startAssistantMessage', () => {
|
||||||
|
it('should reset state for new message', () => {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'First',
|
||||||
|
});
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Second',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
text: 'Second',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processEvent with stream events', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit stream events for text deltas', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const deltaEventCall = calls.find((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return (
|
||||||
|
parsed.type === 'stream_event' &&
|
||||||
|
parsed.event.type === 'content_block_delta'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deltaEventCall).toBeDefined();
|
||||||
|
const parsed = JSON.parse(deltaEventCall![0] as string);
|
||||||
|
expect(parsed.event.type).toBe('content_block_delta');
|
||||||
|
expect(parsed.event.delta).toMatchObject({
|
||||||
|
type: 'text_delta',
|
||||||
|
text: 'Hello',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit message_start event on first content', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'First',
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
const messageStartCall = calls.find((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return (
|
||||||
|
parsed.type === 'stream_event' &&
|
||||||
|
parsed.event.type === 'message_start'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messageStartCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit content_block_start for new blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
const blockStartCall = calls.find((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return (
|
||||||
|
parsed.type === 'stream_event' &&
|
||||||
|
parsed.event.type === 'content_block_start'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(blockStartCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit thinking delta events', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: 'Planning',
|
||||||
|
description: 'Thinking',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
const deltaCall = calls.find((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return (
|
||||||
|
parsed.type === 'stream_event' &&
|
||||||
|
parsed.event.type === 'content_block_delta' &&
|
||||||
|
parsed.event.delta.type === 'thinking_delta'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deltaCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit message_stop on finalization', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text',
|
||||||
|
});
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
const messageStopCall = calls.find((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return (
|
||||||
|
parsed.type === 'stream_event' &&
|
||||||
|
parsed.event.type === 'message_stop'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(messageStopCall).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with partial messages disabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not emit stream events', () => {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
const streamEventCall = calls.find((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return parsed.type === 'stream_event';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(streamEventCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still emit final assistant message', () => {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text',
|
||||||
|
});
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
const assistantCall = calls.find((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return parsed.type === 'assistant';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assistantCall).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processEvent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append text content from Content events', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Hello',
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: ' World',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
text: 'Hello World',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append citation content from Citation events', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Citation,
|
||||||
|
value: 'Citation text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
text: expect.stringContaining('Citation text'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore non-string citation values', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Citation,
|
||||||
|
value: 123,
|
||||||
|
} as unknown as ServerGeminiStreamEvent);
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append thinking from Thought events', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: 'Planning',
|
||||||
|
description: 'Thinking about the task',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'thinking',
|
||||||
|
thinking: 'Planning: Thinking about the task',
|
||||||
|
signature: 'Planning',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle thinking with only subject', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: 'Planning',
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'thinking',
|
||||||
|
signature: 'Planning',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append tool use from ToolCallRequest events', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-call-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
args: { param1: 'value1' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-call-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
input: { param1: 'value1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-call-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
args: { param1: 'value1' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.stop_reason).toBe('tool_use');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stop_reason to null when message contains text blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Some text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.stop_reason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stop_reason to null when message contains thinking blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: {
|
||||||
|
subject: 'Planning',
|
||||||
|
description: 'Thinking about the task',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.stop_reason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-call-1',
|
||||||
|
name: 'test_tool_1',
|
||||||
|
args: { param1: 'value1' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.ToolCallRequest,
|
||||||
|
value: {
|
||||||
|
callId: 'tool-call-2',
|
||||||
|
name: 'test_tool_2',
|
||||||
|
args: { param2: 'value2' },
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
message.message.content.every((block) => block.type === 'tool_use'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(message.message.stop_reason).toBe('tool_use');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update usage from Finished event', () => {
|
||||||
|
const usageMetadata = {
|
||||||
|
promptTokenCount: 100,
|
||||||
|
candidatesTokenCount: 50,
|
||||||
|
cachedContentTokenCount: 10,
|
||||||
|
totalTokenCount: 160,
|
||||||
|
};
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Finished,
|
||||||
|
value: {
|
||||||
|
reason: undefined,
|
||||||
|
usageMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.usage).toMatchObject({
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 50,
|
||||||
|
cache_read_input_tokens: 10,
|
||||||
|
total_tokens: 160,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore events after finalization', () => {
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
const originalContent =
|
||||||
|
adapter.finalizeAssistantMessage().message.content;
|
||||||
|
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Should be ignored',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toEqual(originalContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('finalizeAssistantMessage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build and emit a complete assistant message', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Test response',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
expect(message.type).toBe('assistant');
|
||||||
|
expect(message.uuid).toBeTruthy();
|
||||||
|
expect(message.session_id).toBe('test-session-id');
|
||||||
|
expect(message.parent_tool_use_id).toBeNull();
|
||||||
|
expect(message.message.role).toBe('assistant');
|
||||||
|
expect(message.message.model).toBe('test-model');
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit message to stdout immediately', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
expect(parsed.type).toBe('assistant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store message in lastAssistantMessage', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
// Access protected property for testing
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((adapter as any).lastAssistantMessage).toEqual(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return same message on subsequent calls', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message1 = adapter.finalizeAssistantMessage();
|
||||||
|
const message2 = adapter.finalizeAssistantMessage();
|
||||||
|
|
||||||
|
expect(message1).toEqual(message2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split different block types into separate assistant messages', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text',
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: { subject: 'Thinking', description: 'Thought' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0].type).toBe('thinking');
|
||||||
|
|
||||||
|
const assistantMessages = stdoutWriteSpy.mock.calls
|
||||||
|
.map((call: unknown[]) => JSON.parse(call[0] as string))
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
payload: unknown,
|
||||||
|
): payload is {
|
||||||
|
type: string;
|
||||||
|
message: { content: Array<{ type: string }> };
|
||||||
|
} => {
|
||||||
|
if (
|
||||||
|
typeof payload !== 'object' ||
|
||||||
|
payload === null ||
|
||||||
|
!('type' in payload) ||
|
||||||
|
(payload as { type?: string }).type !== 'assistant' ||
|
||||||
|
!('message' in payload)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const message = (payload as { message?: unknown }).message;
|
||||||
|
if (
|
||||||
|
typeof message !== 'object' ||
|
||||||
|
message === null ||
|
||||||
|
!('content' in message)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const content = (message as { content?: unknown }).content;
|
||||||
|
return (
|
||||||
|
Array.isArray(content) &&
|
||||||
|
content.length > 0 &&
|
||||||
|
content.every(
|
||||||
|
(block: unknown) =>
|
||||||
|
typeof block === 'object' &&
|
||||||
|
block !== null &&
|
||||||
|
'type' in block,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assistantMessages).toHaveLength(2);
|
||||||
|
const observedTypes = assistantMessages.map(
|
||||||
|
(payload: {
|
||||||
|
type: string;
|
||||||
|
message: { content: Array<{ type: string }> };
|
||||||
|
}) => payload.message.content[0]?.type ?? '',
|
||||||
|
);
|
||||||
|
expect(observedTypes).toEqual(['text', 'thinking']);
|
||||||
|
for (const payload of assistantMessages) {
|
||||||
|
const uniqueTypes = new Set(
|
||||||
|
payload.message.content.map((block: { type: string }) => block.type),
|
||||||
|
);
|
||||||
|
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw if message not started', () => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
expect(() => adapter.finalizeAssistantMessage()).toThrow(
|
||||||
|
'Message not started',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitResult', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Response text',
|
||||||
|
});
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit success result immediately', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(parsed.type).toBe('result');
|
||||||
|
expect(parsed.is_error).toBe(false);
|
||||||
|
expect(parsed.subtype).toBe('success');
|
||||||
|
expect(parsed.result).toBe('Response text');
|
||||||
|
expect(parsed.duration_ms).toBe(1000);
|
||||||
|
expect(parsed.num_turns).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit error result', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: true,
|
||||||
|
errorMessage: 'Test error',
|
||||||
|
durationMs: 500,
|
||||||
|
apiDurationMs: 300,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(parsed.is_error).toBe(true);
|
||||||
|
expect(parsed.subtype).toBe('error_during_execution');
|
||||||
|
expect(parsed.error?.message).toBe('Test error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use provided summary over extracted text', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
summary: 'Custom summary',
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(parsed.result).toBe('Custom summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include usage information', () => {
|
||||||
|
const usage = {
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 50,
|
||||||
|
total_tokens: 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
usage,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(parsed.usage).toEqual(usage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle result without assistant message', () => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: 1000,
|
||||||
|
apiDurationMs: 800,
|
||||||
|
numTurns: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(parsed.result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitUserMessage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit user message immediately', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
const parts: Part[] = [{ text: 'Hello user' }];
|
||||||
|
adapter.emitUserMessage(parts);
|
||||||
|
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(parsed.type).toBe('user');
|
||||||
|
expect(Array.isArray(parsed.message.content)).toBe(true);
|
||||||
|
if (Array.isArray(parsed.message.content)) {
|
||||||
|
expect(parsed.message.content).toHaveLength(1);
|
||||||
|
expect(parsed.message.content[0]).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
text: 'Hello user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle parent_tool_use_id', () => {
|
||||||
|
const parts: Part[] = [{ text: 'Tool response' }];
|
||||||
|
adapter.emitUserMessage(parts);
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
// emitUserMessage currently sets parent_tool_use_id to null
|
||||||
|
expect(parsed.parent_tool_use_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitToolResult', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit tool result message immediately', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
const request = {
|
||||||
|
callId: 'tool-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
args: {},
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
};
|
||||||
|
const response = {
|
||||||
|
callId: 'tool-1',
|
||||||
|
responseParts: [],
|
||||||
|
resultDisplay: 'Tool executed successfully',
|
||||||
|
error: undefined,
|
||||||
|
errorType: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.emitToolResult(request, response);
|
||||||
|
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(parsed.type).toBe('user');
|
||||||
|
expect(parsed.parent_tool_use_id).toBeNull();
|
||||||
|
const block = parsed.message.content[0];
|
||||||
|
expect(block).toMatchObject({
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-1',
|
||||||
|
content: 'Tool executed successfully',
|
||||||
|
is_error: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark error tool results', () => {
|
||||||
|
const request = {
|
||||||
|
callId: 'tool-1',
|
||||||
|
name: 'test_tool',
|
||||||
|
args: {},
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-1',
|
||||||
|
};
|
||||||
|
const response = {
|
||||||
|
callId: 'tool-1',
|
||||||
|
responseParts: [],
|
||||||
|
resultDisplay: undefined,
|
||||||
|
error: new Error('Tool failed'),
|
||||||
|
errorType: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.emitToolResult(request, response);
|
||||||
|
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
const block = parsed.message.content[0];
|
||||||
|
expect(block.is_error).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emitSystemMessage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit system message immediately', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.emitSystemMessage('test_subtype', { data: 'value' });
|
||||||
|
|
||||||
|
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||||
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
|
expect(parsed.type).toBe('system');
|
||||||
|
expect(parsed.subtype).toBe('test_subtype');
|
||||||
|
expect(parsed.data).toEqual({ data: 'value' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSessionId and getModel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return session ID from config', () => {
|
||||||
|
expect(adapter.getSessionId()).toBe('test-session-id');
|
||||||
|
expect(mockConfig.getSessionId).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return model from config', () => {
|
||||||
|
expect(adapter.getModel()).toBe('test-model');
|
||||||
|
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message_id in stream events', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include message_id in stream events after message starts', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text',
|
||||||
|
});
|
||||||
|
// Process another event to ensure messageStarted is true
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'More',
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls = stdoutWriteSpy.mock.calls;
|
||||||
|
// Find all delta events
|
||||||
|
const deltaCalls = calls.filter((call: unknown[]) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call[0] as string);
|
||||||
|
return (
|
||||||
|
parsed.type === 'stream_event' &&
|
||||||
|
parsed.event.type === 'content_block_delta'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deltaCalls.length).toBeGreaterThan(0);
|
||||||
|
// The second delta event should have message_id (after messageStarted becomes true)
|
||||||
|
// message_id is added to the event object, so check parsed.event.message_id
|
||||||
|
if (deltaCalls.length > 1) {
|
||||||
|
const secondDelta = JSON.parse(
|
||||||
|
(deltaCalls[1] as unknown[])[0] as string,
|
||||||
|
);
|
||||||
|
// message_id is on the enriched event object
|
||||||
|
expect(
|
||||||
|
secondDelta.event.message_id || secondDelta.message_id,
|
||||||
|
).toBeTruthy();
|
||||||
|
} else {
|
||||||
|
// If only one delta, check if message_id exists
|
||||||
|
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
|
||||||
|
// message_id is added when messageStarted is true
|
||||||
|
// First event may or may not have it, but subsequent ones should
|
||||||
|
expect(delta.event.message_id || delta.message_id).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple text blocks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split assistant messages when block types change repeatedly', () => {
|
||||||
|
stdoutWriteSpy.mockClear();
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Text content',
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Thought,
|
||||||
|
value: { subject: 'Thinking', description: 'Thought' },
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'More text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
text: 'More text',
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessages = stdoutWriteSpy.mock.calls
|
||||||
|
.map((call: unknown[]) => JSON.parse(call[0] as string))
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
payload: unknown,
|
||||||
|
): payload is {
|
||||||
|
type: string;
|
||||||
|
message: { content: Array<{ type: string; text?: string }> };
|
||||||
|
} => {
|
||||||
|
if (
|
||||||
|
typeof payload !== 'object' ||
|
||||||
|
payload === null ||
|
||||||
|
!('type' in payload) ||
|
||||||
|
(payload as { type?: string }).type !== 'assistant' ||
|
||||||
|
!('message' in payload)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const message = (payload as { message?: unknown }).message;
|
||||||
|
if (
|
||||||
|
typeof message !== 'object' ||
|
||||||
|
message === null ||
|
||||||
|
!('content' in message)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const content = (message as { content?: unknown }).content;
|
||||||
|
return (
|
||||||
|
Array.isArray(content) &&
|
||||||
|
content.length > 0 &&
|
||||||
|
content.every(
|
||||||
|
(block: unknown) =>
|
||||||
|
typeof block === 'object' &&
|
||||||
|
block !== null &&
|
||||||
|
'type' in block,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assistantMessages).toHaveLength(3);
|
||||||
|
const observedTypes = assistantMessages.map(
|
||||||
|
(msg: {
|
||||||
|
type: string;
|
||||||
|
message: { content: Array<{ type: string; text?: string }> };
|
||||||
|
}) => msg.message.content[0]?.type ?? '',
|
||||||
|
);
|
||||||
|
expect(observedTypes).toEqual(['text', 'thinking', 'text']);
|
||||||
|
for (const msg of assistantMessages) {
|
||||||
|
const uniqueTypes = new Set(
|
||||||
|
msg.message.content.map((block: { type: string }) => block.type),
|
||||||
|
);
|
||||||
|
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge consecutive text fragments', () => {
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'Hello',
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: ' ',
|
||||||
|
});
|
||||||
|
adapter.processEvent({
|
||||||
|
type: GeminiEventType.Content,
|
||||||
|
value: 'World',
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = adapter.finalizeAssistantMessage();
|
||||||
|
expect(message.message.content).toHaveLength(1);
|
||||||
|
expect(message.message.content[0]).toMatchObject({
|
||||||
|
type: 'text',
|
||||||
|
text: 'Hello World',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import type {
|
||||||
|
CLIAssistantMessage,
|
||||||
|
CLIMessage,
|
||||||
|
CLIPartialAssistantMessage,
|
||||||
|
ControlMessage,
|
||||||
|
StreamEvent,
|
||||||
|
TextBlock,
|
||||||
|
ThinkingBlock,
|
||||||
|
ToolUseBlock,
|
||||||
|
} from '../types.js';
|
||||||
|
import {
|
||||||
|
BaseJsonOutputAdapter,
|
||||||
|
type MessageState,
|
||||||
|
type ResultOptions,
|
||||||
|
type JsonOutputAdapterInterface,
|
||||||
|
} from './BaseJsonOutputAdapter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream JSON output adapter that emits messages immediately
|
||||||
|
* as they are completed during the streaming process.
|
||||||
|
* Supports both main agent and subagent messages through distinct APIs.
|
||||||
|
*/
|
||||||
|
export class StreamJsonOutputAdapter
|
||||||
|
extends BaseJsonOutputAdapter
|
||||||
|
implements JsonOutputAdapterInterface
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
config: Config,
|
||||||
|
private readonly includePartialMessages: boolean,
|
||||||
|
) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits message immediately to stdout (stream mode).
|
||||||
|
*/
|
||||||
|
protected emitMessageImpl(message: CLIMessage | ControlMessage): void {
|
||||||
|
// Track assistant messages for result generation
|
||||||
|
if (
|
||||||
|
typeof message === 'object' &&
|
||||||
|
message !== null &&
|
||||||
|
'type' in message &&
|
||||||
|
message.type === 'assistant'
|
||||||
|
) {
|
||||||
|
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit messages immediately in stream mode
|
||||||
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream mode emits stream events when includePartialMessages is enabled.
|
||||||
|
*/
|
||||||
|
protected shouldEmitStreamEvents(): boolean {
|
||||||
|
return this.includePartialMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||||
|
const state = this.mainAgentMessageState;
|
||||||
|
if (state.finalized) {
|
||||||
|
return this.buildMessage(null);
|
||||||
|
}
|
||||||
|
state.finalized = true;
|
||||||
|
|
||||||
|
this.finalizePendingBlocks(state, null);
|
||||||
|
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
|
||||||
|
(a, b) => a - b,
|
||||||
|
);
|
||||||
|
for (const index of orderedOpenBlocks) {
|
||||||
|
this.onBlockClosed(state, index, null);
|
||||||
|
this.closeBlock(state, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.messageStarted && this.includePartialMessages) {
|
||||||
|
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = this.buildMessage(null);
|
||||||
|
this.updateLastAssistantMessage(message);
|
||||||
|
this.emitMessageImpl(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitResult(options: ResultOptions): void {
|
||||||
|
const resultMessage = this.buildResultMessage(
|
||||||
|
options,
|
||||||
|
this.lastAssistantMessage,
|
||||||
|
);
|
||||||
|
this.emitMessageImpl(resultMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
emitMessage(message: CLIMessage | ControlMessage): void {
|
||||||
|
// In stream mode, emit immediately
|
||||||
|
this.emitMessageImpl(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: CLIMessage | ControlMessage): void {
|
||||||
|
this.emitMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides base class hook to emit stream event when text block is created.
|
||||||
|
*/
|
||||||
|
protected override onTextBlockCreated(
|
||||||
|
state: MessageState,
|
||||||
|
index: number,
|
||||||
|
block: TextBlock,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
this.emitStreamEventIfEnabled(
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index,
|
||||||
|
content_block: block,
|
||||||
|
},
|
||||||
|
parentToolUseId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides base class hook to emit stream event when text is appended.
|
||||||
|
*/
|
||||||
|
protected override onTextAppended(
|
||||||
|
state: MessageState,
|
||||||
|
index: number,
|
||||||
|
fragment: string,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
this.emitStreamEventIfEnabled(
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index,
|
||||||
|
delta: { type: 'text_delta', text: fragment },
|
||||||
|
},
|
||||||
|
parentToolUseId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides base class hook to emit stream event when thinking block is created.
|
||||||
|
*/
|
||||||
|
protected override onThinkingBlockCreated(
|
||||||
|
state: MessageState,
|
||||||
|
index: number,
|
||||||
|
block: ThinkingBlock,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
this.emitStreamEventIfEnabled(
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index,
|
||||||
|
content_block: block,
|
||||||
|
},
|
||||||
|
parentToolUseId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides base class hook to emit stream event when thinking is appended.
|
||||||
|
*/
|
||||||
|
protected override onThinkingAppended(
|
||||||
|
state: MessageState,
|
||||||
|
index: number,
|
||||||
|
fragment: string,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
this.emitStreamEventIfEnabled(
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index,
|
||||||
|
delta: { type: 'thinking_delta', thinking: fragment },
|
||||||
|
},
|
||||||
|
parentToolUseId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides base class hook to emit stream event when tool_use block is created.
|
||||||
|
*/
|
||||||
|
protected override onToolUseBlockCreated(
|
||||||
|
state: MessageState,
|
||||||
|
index: number,
|
||||||
|
block: ToolUseBlock,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
this.emitStreamEventIfEnabled(
|
||||||
|
{
|
||||||
|
type: 'content_block_start',
|
||||||
|
index,
|
||||||
|
content_block: block,
|
||||||
|
},
|
||||||
|
parentToolUseId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides base class hook to emit stream event when tool_use input is set.
|
||||||
|
*/
|
||||||
|
protected override onToolUseInputSet(
|
||||||
|
state: MessageState,
|
||||||
|
index: number,
|
||||||
|
input: unknown,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
this.emitStreamEventIfEnabled(
|
||||||
|
{
|
||||||
|
type: 'content_block_delta',
|
||||||
|
index,
|
||||||
|
delta: {
|
||||||
|
type: 'input_json_delta',
|
||||||
|
partial_json: JSON.stringify(input),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parentToolUseId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides base class hook to emit stream event when block is closed.
|
||||||
|
*/
|
||||||
|
protected override onBlockClosed(
|
||||||
|
state: MessageState,
|
||||||
|
index: number,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
if (this.includePartialMessages) {
|
||||||
|
this.emitStreamEventIfEnabled(
|
||||||
|
{
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
parentToolUseId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides base class hook to emit message_start event when message is started.
|
||||||
|
* Only emits for main agent, not for subagents.
|
||||||
|
*/
|
||||||
|
protected override onEnsureMessageStarted(
|
||||||
|
state: MessageState,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
// Only emit message_start for main agent, not for subagents
|
||||||
|
if (parentToolUseId === null) {
|
||||||
|
this.emitStreamEventIfEnabled(
|
||||||
|
{
|
||||||
|
type: 'message_start',
|
||||||
|
message: {
|
||||||
|
id: state.messageId!,
|
||||||
|
role: 'assistant',
|
||||||
|
model: this.config.getModel(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits stream events when partial messages are enabled.
|
||||||
|
* This is a private method specific to StreamJsonOutputAdapter.
|
||||||
|
* @param event - Stream event to emit
|
||||||
|
* @param parentToolUseId - null for main agent, string for subagent
|
||||||
|
*/
|
||||||
|
private emitStreamEventIfEnabled(
|
||||||
|
event: StreamEvent,
|
||||||
|
parentToolUseId: string | null,
|
||||||
|
): void {
|
||||||
|
if (!this.includePartialMessages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.getMessageState(parentToolUseId);
|
||||||
|
const enrichedEvent = state.messageStarted
|
||||||
|
? ({ ...event, message_id: state.messageId } as StreamEvent & {
|
||||||
|
message_id: string;
|
||||||
|
})
|
||||||
|
: event;
|
||||||
|
|
||||||
|
const partial: CLIPartialAssistantMessage = {
|
||||||
|
type: 'stream_event',
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: this.getSessionId(),
|
||||||
|
parent_tool_use_id: parentToolUseId,
|
||||||
|
event: enrichedEvent,
|
||||||
|
};
|
||||||
|
this.emitMessageImpl(partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
591
packages/cli/src/nonInteractive/session.test.ts
Normal file
591
packages/cli/src/nonInteractive/session.test.ts
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import { runNonInteractiveStreamJson } from './session.js';
|
||||||
|
import type {
|
||||||
|
CLIUserMessage,
|
||||||
|
CLIControlRequest,
|
||||||
|
CLIControlResponse,
|
||||||
|
ControlCancelRequest,
|
||||||
|
} from './types.js';
|
||||||
|
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||||
|
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||||
|
import { ControlDispatcher } from './control/ControlDispatcher.js';
|
||||||
|
import { ControlContext } from './control/ControlContext.js';
|
||||||
|
import { ControlService } from './control/ControlService.js';
|
||||||
|
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||||
|
|
||||||
|
const runNonInteractiveMock = vi.fn();
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('../nonInteractiveCli.js', () => ({
|
||||||
|
runNonInteractive: (...args: unknown[]) => runNonInteractiveMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./io/StreamJsonInputReader.js', () => ({
|
||||||
|
StreamJsonInputReader: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./io/StreamJsonOutputAdapter.js', () => ({
|
||||||
|
StreamJsonOutputAdapter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./control/ControlDispatcher.js', () => ({
|
||||||
|
ControlDispatcher: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./control/ControlContext.js', () => ({
|
||||||
|
ControlContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./control/ControlService.js', () => ({
|
||||||
|
ControlService: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ui/utils/ConsolePatcher.js', () => ({
|
||||||
|
ConsolePatcher: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface ConfigOverrides {
|
||||||
|
getSessionId?: () => string;
|
||||||
|
getModel?: () => string;
|
||||||
|
getIncludePartialMessages?: () => boolean;
|
||||||
|
getDebugMode?: () => boolean;
|
||||||
|
getApprovalMode?: () => string;
|
||||||
|
getOutputFormat?: () => string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfig(overrides: ConfigOverrides = {}): Config {
|
||||||
|
const base = {
|
||||||
|
getSessionId: () => 'test-session',
|
||||||
|
getModel: () => 'test-model',
|
||||||
|
getIncludePartialMessages: () => false,
|
||||||
|
getDebugMode: () => false,
|
||||||
|
getApprovalMode: () => 'auto',
|
||||||
|
getOutputFormat: () => 'stream-json',
|
||||||
|
};
|
||||||
|
return { ...base, ...overrides } as unknown as Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUserMessage(content: string): CLIUserMessage {
|
||||||
|
return {
|
||||||
|
type: 'user',
|
||||||
|
session_id: 'test-session',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createControlRequest(
|
||||||
|
subtype: 'initialize' | 'set_model' | 'interrupt' = 'initialize',
|
||||||
|
): CLIControlRequest {
|
||||||
|
if (subtype === 'set_model') {
|
||||||
|
return {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-1',
|
||||||
|
request: {
|
||||||
|
subtype: 'set_model',
|
||||||
|
model: 'test-model',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subtype === 'interrupt') {
|
||||||
|
return {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-1',
|
||||||
|
request: {
|
||||||
|
subtype: 'interrupt',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'control_request',
|
||||||
|
request_id: 'req-1',
|
||||||
|
request: {
|
||||||
|
subtype: 'initialize',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createControlResponse(requestId: string): CLIControlResponse {
|
||||||
|
return {
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createControlCancel(requestId: string): ControlCancelRequest {
|
||||||
|
return {
|
||||||
|
type: 'control_cancel_request',
|
||||||
|
request_id: requestId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runNonInteractiveStreamJson', () => {
|
||||||
|
let config: Config;
|
||||||
|
let mockInputReader: {
|
||||||
|
read: () => AsyncGenerator<
|
||||||
|
| CLIUserMessage
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
let mockOutputAdapter: {
|
||||||
|
emitResult: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockDispatcher: {
|
||||||
|
dispatch: ReturnType<typeof vi.fn>;
|
||||||
|
handleControlResponse: ReturnType<typeof vi.fn>;
|
||||||
|
handleCancel: ReturnType<typeof vi.fn>;
|
||||||
|
shutdown: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let mockConsolePatcher: {
|
||||||
|
patch: ReturnType<typeof vi.fn>;
|
||||||
|
cleanup: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config = createConfig();
|
||||||
|
runNonInteractiveMock.mockReset();
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
mockConsolePatcher = {
|
||||||
|
patch: vi.fn(),
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
};
|
||||||
|
(ConsolePatcher as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
() => mockConsolePatcher,
|
||||||
|
);
|
||||||
|
|
||||||
|
mockOutputAdapter = {
|
||||||
|
emitResult: vi.fn(),
|
||||||
|
} as {
|
||||||
|
emitResult: ReturnType<typeof vi.fn>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
(
|
||||||
|
StreamJsonOutputAdapter as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockImplementation(() => mockOutputAdapter);
|
||||||
|
|
||||||
|
mockDispatcher = {
|
||||||
|
dispatch: vi.fn().mockResolvedValue(undefined),
|
||||||
|
handleControlResponse: vi.fn(),
|
||||||
|
handleCancel: vi.fn(),
|
||||||
|
shutdown: vi.fn(),
|
||||||
|
};
|
||||||
|
(
|
||||||
|
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockImplementation(() => mockDispatcher);
|
||||||
|
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
() => ({}),
|
||||||
|
);
|
||||||
|
(ControlService as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
() => ({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockInputReader = {
|
||||||
|
async *read() {
|
||||||
|
// Default: empty stream
|
||||||
|
// Override in tests as needed
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(
|
||||||
|
StreamJsonInputReader as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockImplementation(() => mockInputReader);
|
||||||
|
|
||||||
|
runNonInteractiveMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes session and processes initialize control request', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
|
||||||
|
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes user message when received as first message', async () => {
|
||||||
|
const userMessage = createUserMessage('Hello world');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield userMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||||
|
const runCall = runNonInteractiveMock.mock.calls[0];
|
||||||
|
expect(runCall[2]).toBe('Hello world'); // Direct text, not processed
|
||||||
|
expect(typeof runCall[3]).toBe('string'); // promptId
|
||||||
|
expect(runCall[4]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
abortController: expect.any(AbortController),
|
||||||
|
adapter: mockOutputAdapter,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes multiple user messages sequentially', async () => {
|
||||||
|
// Initialize first to enable multi-query mode
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const userMessage1 = createUserMessage('First message');
|
||||||
|
const userMessage2 = createUserMessage('Second message');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
yield userMessage1;
|
||||||
|
yield userMessage2;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enqueues user messages received during processing', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const userMessage1 = createUserMessage('First message');
|
||||||
|
const userMessage2 = createUserMessage('Second message');
|
||||||
|
|
||||||
|
// Make runNonInteractive take some time to simulate processing
|
||||||
|
runNonInteractiveMock.mockImplementation(
|
||||||
|
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
yield userMessage1;
|
||||||
|
yield userMessage2;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
// Both messages should be processed
|
||||||
|
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes control request in idle state', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const controlRequest = createControlRequest('set_model');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
yield controlRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(1, initRequest);
|
||||||
|
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(2, controlRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles control response in idle state', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const controlResponse = createControlResponse('req-2');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
yield controlResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
|
||||||
|
controlResponse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles control cancel in idle state', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const cancelRequest = createControlCancel('req-2');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
yield cancelRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockDispatcher.handleCancel).toHaveBeenCalledWith('req-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles control request during processing state', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const userMessage = createUserMessage('Process me');
|
||||||
|
const controlRequest = createControlRequest('set_model');
|
||||||
|
|
||||||
|
runNonInteractiveMock.mockImplementation(
|
||||||
|
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
yield userMessage;
|
||||||
|
yield controlRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(controlRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles control response during processing state', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const userMessage = createUserMessage('Process me');
|
||||||
|
const controlResponse = createControlResponse('req-1');
|
||||||
|
|
||||||
|
runNonInteractiveMock.mockImplementation(
|
||||||
|
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||||
|
);
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
yield userMessage;
|
||||||
|
yield controlResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
|
||||||
|
controlResponse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles user message with text content', async () => {
|
||||||
|
const userMessage = createUserMessage('Test message');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield userMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runNonInteractiveMock).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
expect.objectContaining({ merged: expect.any(Object) }),
|
||||||
|
'Test message',
|
||||||
|
expect.stringContaining('test-session'),
|
||||||
|
expect.objectContaining({
|
||||||
|
abortController: expect.any(AbortController),
|
||||||
|
adapter: mockOutputAdapter,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles user message with array content blocks', async () => {
|
||||||
|
const userMessage: CLIUserMessage = {
|
||||||
|
type: 'user',
|
||||||
|
session_id: 'test-session',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'First part' },
|
||||||
|
{ type: 'text', text: 'Second part' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield userMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runNonInteractiveMock).toHaveBeenCalledWith(
|
||||||
|
config,
|
||||||
|
expect.objectContaining({ merged: expect.any(Object) }),
|
||||||
|
'First part\nSecond part',
|
||||||
|
expect.stringContaining('test-session'),
|
||||||
|
expect.objectContaining({
|
||||||
|
abortController: expect.any(AbortController),
|
||||||
|
adapter: mockOutputAdapter,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips user message with no text content', async () => {
|
||||||
|
const userMessage: CLIUserMessage = {
|
||||||
|
type: 'user',
|
||||||
|
session_id: 'test-session',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield userMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(runNonInteractiveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles error from processUserMessage', async () => {
|
||||||
|
const userMessage = createUserMessage('Test message');
|
||||||
|
|
||||||
|
const error = new Error('Processing error');
|
||||||
|
runNonInteractiveMock.mockRejectedValue(error);
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield userMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
// Error should be caught and handled gracefully
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles stream error gracefully', async () => {
|
||||||
|
const streamError = new Error('Stream error');
|
||||||
|
// eslint-disable-next-line require-yield
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
throw streamError;
|
||||||
|
} as typeof mockInputReader.read;
|
||||||
|
|
||||||
|
await expect(runNonInteractiveStreamJson(config, '')).rejects.toThrow(
|
||||||
|
'Stream error',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops processing when abort signal is triggered', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const userMessage = createUserMessage('Test message');
|
||||||
|
|
||||||
|
// Capture abort signal from ControlContext
|
||||||
|
let abortSignal: AbortSignal | null = null;
|
||||||
|
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||||
|
(options: { abortSignal?: AbortSignal }) => {
|
||||||
|
abortSignal = options.abortSignal ?? null;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create input reader that aborts after first message
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
// Abort the signal after initialization
|
||||||
|
if (abortSignal && !abortSignal.aborted) {
|
||||||
|
// The signal doesn't have an abort method, but the controller does
|
||||||
|
// Since we can't access the controller directly, we'll test by
|
||||||
|
// verifying that cleanup happens properly
|
||||||
|
}
|
||||||
|
// Yield second message - if abort works, it should be checked
|
||||||
|
yield userMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
// Verify initialization happened
|
||||||
|
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
|
||||||
|
expect(mockDispatcher.shutdown).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique prompt IDs for each message', async () => {
|
||||||
|
// Initialize first to enable multi-query mode
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
const userMessage1 = createUserMessage('First');
|
||||||
|
const userMessage2 = createUserMessage('Second');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
yield userMessage1;
|
||||||
|
yield userMessage2;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||||
|
const promptId1 = runNonInteractiveMock.mock.calls[0][3] as string;
|
||||||
|
const promptId2 = runNonInteractiveMock.mock.calls[1][3] as string;
|
||||||
|
expect(promptId1).not.toBe(promptId2);
|
||||||
|
expect(promptId1).toContain('test-session');
|
||||||
|
expect(promptId2).toContain('test-session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-initialize control request during initialization', async () => {
|
||||||
|
const controlRequest = createControlRequest('set_model');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield controlRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
// Should not transition to idle since it's not an initialize request
|
||||||
|
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up console patcher on completion', async () => {
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
// Empty stream - should complete immediately
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up output adapter on completion', async () => {
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
// Empty stream
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls dispatcher shutdown on completion', async () => {
|
||||||
|
const initRequest = createControlRequest('initialize');
|
||||||
|
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
yield initRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockDispatcher.shutdown).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty stream gracefully', async () => {
|
||||||
|
mockInputReader.read = async function* () {
|
||||||
|
// Empty stream
|
||||||
|
};
|
||||||
|
|
||||||
|
await runNonInteractiveStreamJson(config, '');
|
||||||
|
|
||||||
|
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
721
packages/cli/src/nonInteractive/session.ts
Normal file
721
packages/cli/src/nonInteractive/session.ts
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream JSON Runner with Session State Machine
|
||||||
|
*
|
||||||
|
* Handles stream-json input/output format with:
|
||||||
|
* - Initialize handshake
|
||||||
|
* - Message routing (control vs user messages)
|
||||||
|
* - FIFO user message queue
|
||||||
|
* - Sequential message processing
|
||||||
|
* - Graceful shutdown
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
|
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||||
|
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||||
|
import { ControlContext } from './control/ControlContext.js';
|
||||||
|
import { ControlDispatcher } from './control/ControlDispatcher.js';
|
||||||
|
import { ControlService } from './control/ControlService.js';
|
||||||
|
import type {
|
||||||
|
CLIMessage,
|
||||||
|
CLIUserMessage,
|
||||||
|
CLIControlRequest,
|
||||||
|
CLIControlResponse,
|
||||||
|
ControlCancelRequest,
|
||||||
|
} from './types.js';
|
||||||
|
import {
|
||||||
|
isCLIUserMessage,
|
||||||
|
isCLIAssistantMessage,
|
||||||
|
isCLISystemMessage,
|
||||||
|
isCLIResultMessage,
|
||||||
|
isCLIPartialAssistantMessage,
|
||||||
|
isControlRequest,
|
||||||
|
isControlResponse,
|
||||||
|
isControlCancel,
|
||||||
|
} from './types.js';
|
||||||
|
import { createMinimalSettings } from '../config/settings.js';
|
||||||
|
import { runNonInteractive } from '../nonInteractiveCli.js';
|
||||||
|
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||||
|
|
||||||
|
const SESSION_STATE = {
|
||||||
|
INITIALIZING: 'initializing',
|
||||||
|
IDLE: 'idle',
|
||||||
|
PROCESSING_QUERY: 'processing_query',
|
||||||
|
SHUTTING_DOWN: 'shutting_down',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message type classification for routing
|
||||||
|
*/
|
||||||
|
type MessageType =
|
||||||
|
| 'control_request'
|
||||||
|
| 'control_response'
|
||||||
|
| 'control_cancel'
|
||||||
|
| 'user'
|
||||||
|
| 'assistant'
|
||||||
|
| 'system'
|
||||||
|
| 'result'
|
||||||
|
| 'stream_event'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routed message with classification
|
||||||
|
*/
|
||||||
|
interface RoutedMessage {
|
||||||
|
type: MessageType;
|
||||||
|
message:
|
||||||
|
| CLIMessage
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session Manager
|
||||||
|
*
|
||||||
|
* Manages the session lifecycle and message processing state machine.
|
||||||
|
*/
|
||||||
|
class SessionManager {
|
||||||
|
private state: SessionState = SESSION_STATE.INITIALIZING;
|
||||||
|
private userMessageQueue: CLIUserMessage[] = [];
|
||||||
|
private abortController: AbortController;
|
||||||
|
private config: Config;
|
||||||
|
private sessionId: string;
|
||||||
|
private promptIdCounter: number = 0;
|
||||||
|
private inputReader: StreamJsonInputReader;
|
||||||
|
private outputAdapter: StreamJsonOutputAdapter;
|
||||||
|
private controlContext: ControlContext | null = null;
|
||||||
|
private dispatcher: ControlDispatcher | null = null;
|
||||||
|
private controlService: ControlService | null = null;
|
||||||
|
private controlSystemEnabled: boolean | null = null;
|
||||||
|
private debugMode: boolean;
|
||||||
|
private shutdownHandler: (() => void) | null = null;
|
||||||
|
private initialPrompt: CLIUserMessage | null = null;
|
||||||
|
|
||||||
|
constructor(config: Config, initialPrompt?: CLIUserMessage) {
|
||||||
|
this.config = config;
|
||||||
|
this.sessionId = config.getSessionId();
|
||||||
|
this.debugMode = config.getDebugMode();
|
||||||
|
this.abortController = new AbortController();
|
||||||
|
this.initialPrompt = initialPrompt ?? null;
|
||||||
|
|
||||||
|
this.inputReader = new StreamJsonInputReader();
|
||||||
|
this.outputAdapter = new StreamJsonOutputAdapter(
|
||||||
|
config,
|
||||||
|
config.getIncludePartialMessages(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup signal handlers for graceful shutdown
|
||||||
|
this.setupSignalHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next prompt ID
|
||||||
|
*/
|
||||||
|
private getNextPromptId(): string {
|
||||||
|
this.promptIdCounter++;
|
||||||
|
return `${this.sessionId}########${this.promptIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a message to the appropriate handler based on its type
|
||||||
|
*
|
||||||
|
* Classifies incoming messages and routes them to appropriate handlers.
|
||||||
|
*/
|
||||||
|
private route(
|
||||||
|
message:
|
||||||
|
| CLIMessage
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest,
|
||||||
|
): RoutedMessage {
|
||||||
|
// Check control messages first
|
||||||
|
if (isControlRequest(message)) {
|
||||||
|
return { type: 'control_request', message };
|
||||||
|
}
|
||||||
|
if (isControlResponse(message)) {
|
||||||
|
return { type: 'control_response', message };
|
||||||
|
}
|
||||||
|
if (isControlCancel(message)) {
|
||||||
|
return { type: 'control_cancel', message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check data messages
|
||||||
|
if (isCLIUserMessage(message)) {
|
||||||
|
return { type: 'user', message };
|
||||||
|
}
|
||||||
|
if (isCLIAssistantMessage(message)) {
|
||||||
|
return { type: 'assistant', message };
|
||||||
|
}
|
||||||
|
if (isCLISystemMessage(message)) {
|
||||||
|
return { type: 'system', message };
|
||||||
|
}
|
||||||
|
if (isCLIResultMessage(message)) {
|
||||||
|
return { type: 'result', message };
|
||||||
|
}
|
||||||
|
if (isCLIPartialAssistantMessage(message)) {
|
||||||
|
return { type: 'stream_event', message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown message type
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Unknown message type:',
|
||||||
|
JSON.stringify(message, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { type: 'unknown', message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single message with unified logic for both initial prompt and stream messages.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Abort check
|
||||||
|
* - First message detection and handling
|
||||||
|
* - Normal message processing
|
||||||
|
* - Shutdown state checks
|
||||||
|
*
|
||||||
|
* @param message - Message to process
|
||||||
|
* @returns true if the calling code should exit (break/return), false to continue
|
||||||
|
*/
|
||||||
|
private async processSingleMessage(
|
||||||
|
message:
|
||||||
|
| CLIMessage
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Check for abort
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle first message if control system not yet initialized
|
||||||
|
if (this.controlSystemEnabled === null) {
|
||||||
|
const handled = await this.handleFirstMessage(message);
|
||||||
|
if (handled) {
|
||||||
|
// If handled, check if we should shutdown
|
||||||
|
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||||
|
}
|
||||||
|
// If not handled, fall through to normal processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process message normally
|
||||||
|
await this.processMessage(message);
|
||||||
|
|
||||||
|
// Check for shutdown after processing
|
||||||
|
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point - run the session
|
||||||
|
*/
|
||||||
|
async run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Starting session', this.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process initial prompt if provided
|
||||||
|
if (this.initialPrompt !== null) {
|
||||||
|
const shouldExit = await this.processSingleMessage(this.initialPrompt);
|
||||||
|
if (shouldExit) {
|
||||||
|
await this.shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process messages from stream
|
||||||
|
for await (const message of this.inputReader.read()) {
|
||||||
|
const shouldExit = await this.processSingleMessage(message);
|
||||||
|
if (shouldExit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream closed, shutdown
|
||||||
|
await this.shutdown();
|
||||||
|
} catch (error) {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Error:', error);
|
||||||
|
}
|
||||||
|
await this.shutdown();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
// Ensure signal handlers are always cleaned up even if shutdown wasn't called
|
||||||
|
this.cleanupSignalHandlers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureControlSystem(): void {
|
||||||
|
if (this.controlContext && this.dispatcher && this.controlService) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The control system follows a strict three-layer architecture:
|
||||||
|
// 1. ControlContext (shared session state)
|
||||||
|
// 2. ControlDispatcher (protocol routing SDK ↔ CLI)
|
||||||
|
// 3. ControlService (programmatic API for CLI runtime)
|
||||||
|
//
|
||||||
|
// Application code MUST interact with the control plane exclusively through
|
||||||
|
// ControlService. ControlDispatcher is reserved for protocol-level message
|
||||||
|
// routing and should never be used directly outside of this file.
|
||||||
|
this.controlContext = new ControlContext({
|
||||||
|
config: this.config,
|
||||||
|
streamJson: this.outputAdapter,
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
abortSignal: this.abortController.signal,
|
||||||
|
permissionMode: this.config.getApprovalMode(),
|
||||||
|
onInterrupt: () => this.handleInterrupt(),
|
||||||
|
});
|
||||||
|
this.dispatcher = new ControlDispatcher(this.controlContext);
|
||||||
|
this.controlService = new ControlService(
|
||||||
|
this.controlContext,
|
||||||
|
this.dispatcher,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDispatcher(): ControlDispatcher | null {
|
||||||
|
if (this.controlSystemEnabled !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.dispatcher) {
|
||||||
|
this.ensureControlSystem();
|
||||||
|
}
|
||||||
|
return this.dispatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleFirstMessage(
|
||||||
|
message:
|
||||||
|
| CLIMessage
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const routed = this.route(message);
|
||||||
|
|
||||||
|
if (routed.type === 'control_request') {
|
||||||
|
const request = routed.message as CLIControlRequest;
|
||||||
|
this.controlSystemEnabled = true;
|
||||||
|
this.ensureControlSystem();
|
||||||
|
if (request.request.subtype === 'initialize') {
|
||||||
|
await this.dispatcher?.dispatch(request);
|
||||||
|
this.state = SESSION_STATE.IDLE;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routed.type === 'user') {
|
||||||
|
this.controlSystemEnabled = false;
|
||||||
|
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||||
|
this.userMessageQueue.push(routed.message as CLIUserMessage);
|
||||||
|
await this.processUserMessageQueue();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controlSystemEnabled = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single message from the stream
|
||||||
|
*/
|
||||||
|
private async processMessage(
|
||||||
|
message:
|
||||||
|
| CLIMessage
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
const routed = this.route(message);
|
||||||
|
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
`[SessionManager] State: ${this.state}, Message type: ${routed.type}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.state) {
|
||||||
|
case SESSION_STATE.INITIALIZING:
|
||||||
|
await this.handleInitializingState(routed);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SESSION_STATE.IDLE:
|
||||||
|
await this.handleIdleState(routed);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SESSION_STATE.PROCESSING_QUERY:
|
||||||
|
await this.handleProcessingState(routed);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SESSION_STATE.SHUTTING_DOWN:
|
||||||
|
// Ignore all messages during shutdown
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustive check
|
||||||
|
const _exhaustiveCheck: never = this.state;
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Unknown state:', _exhaustiveCheck);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle messages in initializing state
|
||||||
|
*/
|
||||||
|
private async handleInitializingState(routed: RoutedMessage): Promise<void> {
|
||||||
|
if (routed.type === 'control_request') {
|
||||||
|
const request = routed.message as CLIControlRequest;
|
||||||
|
const dispatcher = this.getDispatcher();
|
||||||
|
if (!dispatcher) {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Control request received before control system initialization',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.request.subtype === 'initialize') {
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
this.state = SESSION_STATE.IDLE;
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Initialized, transitioning to idle');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Ignoring non-initialize control request during initialization',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Ignoring non-control message during initialization',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle messages in idle state
|
||||||
|
*/
|
||||||
|
private async handleIdleState(routed: RoutedMessage): Promise<void> {
|
||||||
|
const dispatcher = this.getDispatcher();
|
||||||
|
if (routed.type === 'control_request') {
|
||||||
|
if (!dispatcher) {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Ignoring control request (disabled)');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = routed.message as CLIControlRequest;
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
// Stay in idle state
|
||||||
|
} else if (routed.type === 'control_response') {
|
||||||
|
if (!dispatcher) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = routed.message as CLIControlResponse;
|
||||||
|
dispatcher.handleControlResponse(response);
|
||||||
|
// Stay in idle state
|
||||||
|
} else if (routed.type === 'control_cancel') {
|
||||||
|
if (!dispatcher) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cancelRequest = routed.message as ControlCancelRequest;
|
||||||
|
dispatcher.handleCancel(cancelRequest.request_id);
|
||||||
|
} else if (routed.type === 'user') {
|
||||||
|
const userMessage = routed.message as CLIUserMessage;
|
||||||
|
this.userMessageQueue.push(userMessage);
|
||||||
|
// Start processing queue
|
||||||
|
await this.processUserMessageQueue();
|
||||||
|
} else {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Ignoring message type in idle state:',
|
||||||
|
routed.type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle messages in processing state
|
||||||
|
*/
|
||||||
|
private async handleProcessingState(routed: RoutedMessage): Promise<void> {
|
||||||
|
const dispatcher = this.getDispatcher();
|
||||||
|
if (routed.type === 'control_request') {
|
||||||
|
if (!dispatcher) {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Control request ignored during processing (disabled)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = routed.message as CLIControlRequest;
|
||||||
|
await dispatcher.dispatch(request);
|
||||||
|
// Continue processing
|
||||||
|
} else if (routed.type === 'control_response') {
|
||||||
|
if (!dispatcher) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = routed.message as CLIControlResponse;
|
||||||
|
dispatcher.handleControlResponse(response);
|
||||||
|
// Continue processing
|
||||||
|
} else if (routed.type === 'user') {
|
||||||
|
// Enqueue for later
|
||||||
|
const userMessage = routed.message as CLIUserMessage;
|
||||||
|
this.userMessageQueue.push(userMessage);
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Enqueued user message during processing',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Ignoring message type during processing:',
|
||||||
|
routed.type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process user message queue (FIFO)
|
||||||
|
*/
|
||||||
|
private async processUserMessageQueue(): Promise<void> {
|
||||||
|
while (
|
||||||
|
this.userMessageQueue.length > 0 &&
|
||||||
|
!this.abortController.signal.aborted
|
||||||
|
) {
|
||||||
|
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||||
|
const userMessage = this.userMessageQueue.shift()!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.processUserMessage(userMessage);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Error processing user message:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Send error result
|
||||||
|
this.emitErrorResult(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If control system is disabled (single-query mode) and queue is empty,
|
||||||
|
// automatically shutdown instead of returning to idle
|
||||||
|
if (
|
||||||
|
!this.abortController.signal.aborted &&
|
||||||
|
this.state === SESSION_STATE.PROCESSING_QUERY &&
|
||||||
|
this.controlSystemEnabled === false &&
|
||||||
|
this.userMessageQueue.length === 0
|
||||||
|
) {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error(
|
||||||
|
'[SessionManager] Single-query mode: queue processed, shutting down',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return to idle after processing queue (for multi-query mode with control system)
|
||||||
|
if (
|
||||||
|
!this.abortController.signal.aborted &&
|
||||||
|
this.state === SESSION_STATE.PROCESSING_QUERY
|
||||||
|
) {
|
||||||
|
this.state = SESSION_STATE.IDLE;
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Queue processed, returning to idle');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single user message
|
||||||
|
*/
|
||||||
|
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
|
||||||
|
const input = extractUserMessageText(userMessage);
|
||||||
|
if (!input) {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] No text content in user message');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptId = this.getNextPromptId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runNonInteractive(
|
||||||
|
this.config,
|
||||||
|
createMinimalSettings(),
|
||||||
|
input,
|
||||||
|
promptId,
|
||||||
|
{
|
||||||
|
abortController: this.abortController,
|
||||||
|
adapter: this.outputAdapter,
|
||||||
|
controlService: this.controlService ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Error already handled by runNonInteractive via adapter.emitResult
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Query execution error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send tool results as user message
|
||||||
|
*/
|
||||||
|
private emitErrorResult(
|
||||||
|
error: unknown,
|
||||||
|
numTurns: number = 0,
|
||||||
|
durationMs: number = 0,
|
||||||
|
apiDurationMs: number = 0,
|
||||||
|
): void {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.outputAdapter.emitResult({
|
||||||
|
isError: true,
|
||||||
|
errorMessage: message,
|
||||||
|
durationMs,
|
||||||
|
apiDurationMs,
|
||||||
|
numTurns,
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle interrupt control request
|
||||||
|
*/
|
||||||
|
private handleInterrupt(): void {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Interrupt requested');
|
||||||
|
}
|
||||||
|
// Abort current query if processing
|
||||||
|
if (this.state === SESSION_STATE.PROCESSING_QUERY) {
|
||||||
|
this.abortController.abort();
|
||||||
|
this.abortController = new AbortController(); // Create new controller for next query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup signal handlers for graceful shutdown
|
||||||
|
*/
|
||||||
|
private setupSignalHandlers(): void {
|
||||||
|
this.shutdownHandler = () => {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Shutdown signal received');
|
||||||
|
}
|
||||||
|
this.abortController.abort();
|
||||||
|
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', this.shutdownHandler);
|
||||||
|
process.on('SIGTERM', this.shutdownHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shutdown session and cleanup resources
|
||||||
|
*/
|
||||||
|
private async shutdown(): Promise<void> {
|
||||||
|
if (this.debugMode) {
|
||||||
|
console.error('[SessionManager] Shutting down');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||||
|
this.dispatcher?.shutdown();
|
||||||
|
this.cleanupSignalHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove signal handlers to prevent memory leaks
|
||||||
|
*/
|
||||||
|
private cleanupSignalHandlers(): void {
|
||||||
|
if (this.shutdownHandler) {
|
||||||
|
process.removeListener('SIGINT', this.shutdownHandler);
|
||||||
|
process.removeListener('SIGTERM', this.shutdownHandler);
|
||||||
|
this.shutdownHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUserMessageText(message: CLIUserMessage): string | null {
|
||||||
|
const content = message.message.content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const parts = content
|
||||||
|
.map((block) => {
|
||||||
|
if (!block || typeof block !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if ('type' in block && block.type === 'text' && 'text' in block) {
|
||||||
|
return typeof block.text === 'string' ? block.text : '';
|
||||||
|
}
|
||||||
|
return JSON.stringify(block);
|
||||||
|
})
|
||||||
|
.filter((part) => part.length > 0);
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join('\n') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point for stream-json mode
|
||||||
|
*
|
||||||
|
* @param config - Configuration object
|
||||||
|
* @param input - Optional initial prompt input to process before reading from stream
|
||||||
|
*/
|
||||||
|
export async function runNonInteractiveStreamJson(
|
||||||
|
config: Config,
|
||||||
|
input: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const consolePatcher = new ConsolePatcher({
|
||||||
|
debugMode: config.getDebugMode(),
|
||||||
|
});
|
||||||
|
consolePatcher.patch();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create initial user message from prompt input if provided
|
||||||
|
let initialPrompt: CLIUserMessage | undefined = undefined;
|
||||||
|
if (input && input.trim().length > 0) {
|
||||||
|
const sessionId = config.getSessionId();
|
||||||
|
initialPrompt = {
|
||||||
|
type: 'user',
|
||||||
|
session_id: sessionId,
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: input.trim(),
|
||||||
|
},
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new SessionManager(config, initialPrompt);
|
||||||
|
await manager.run();
|
||||||
|
} finally {
|
||||||
|
consolePatcher.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
509
packages/cli/src/nonInteractive/types.ts
Normal file
509
packages/cli/src/nonInteractive/types.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation for attaching metadata to content blocks
|
||||||
|
*/
|
||||||
|
export interface Annotation {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage information types
|
||||||
|
*/
|
||||||
|
export interface Usage {
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
cache_creation_input_tokens?: number;
|
||||||
|
cache_read_input_tokens?: number;
|
||||||
|
total_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtendedUsage extends Usage {
|
||||||
|
server_tool_use?: {
|
||||||
|
web_search_requests: number;
|
||||||
|
};
|
||||||
|
service_tier?: string;
|
||||||
|
cache_creation?: {
|
||||||
|
ephemeral_1h_input_tokens: number;
|
||||||
|
ephemeral_5m_input_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelUsage {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheReadInputTokens: number;
|
||||||
|
cacheCreationInputTokens: number;
|
||||||
|
webSearchRequests: number;
|
||||||
|
contextWindow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission denial information
|
||||||
|
*/
|
||||||
|
export interface CLIPermissionDenial {
|
||||||
|
tool_name: string;
|
||||||
|
tool_use_id: string;
|
||||||
|
tool_input: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content block types from Anthropic SDK
|
||||||
|
*/
|
||||||
|
export interface TextBlock {
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThinkingBlock {
|
||||||
|
type: 'thinking';
|
||||||
|
thinking: string;
|
||||||
|
signature?: string;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolUseBlock {
|
||||||
|
type: 'tool_use';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: unknown;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolResultBlock {
|
||||||
|
type: 'tool_result';
|
||||||
|
tool_use_id: string;
|
||||||
|
content?: string | ContentBlock[];
|
||||||
|
is_error?: boolean;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentBlock =
|
||||||
|
| TextBlock
|
||||||
|
| ThinkingBlock
|
||||||
|
| ToolUseBlock
|
||||||
|
| ToolResultBlock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anthropic SDK Message types
|
||||||
|
*/
|
||||||
|
export interface APIUserMessage {
|
||||||
|
role: 'user';
|
||||||
|
content: string | ContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIAssistantMessage {
|
||||||
|
id: string;
|
||||||
|
type: 'message';
|
||||||
|
role: 'assistant';
|
||||||
|
model: string;
|
||||||
|
content: ContentBlock[];
|
||||||
|
stop_reason?: string | null;
|
||||||
|
usage: Usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI Message wrapper types
|
||||||
|
*/
|
||||||
|
export interface CLIUserMessage {
|
||||||
|
type: 'user';
|
||||||
|
uuid?: string;
|
||||||
|
session_id: string;
|
||||||
|
message: APIUserMessage;
|
||||||
|
parent_tool_use_id: string | null;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIAssistantMessage {
|
||||||
|
type: 'assistant';
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
message: APIAssistantMessage;
|
||||||
|
parent_tool_use_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLISystemMessage {
|
||||||
|
type: 'system';
|
||||||
|
subtype: string;
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
data?: unknown;
|
||||||
|
cwd?: string;
|
||||||
|
tools?: string[];
|
||||||
|
mcp_servers?: Array<{
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
model?: string;
|
||||||
|
permissionMode?: string;
|
||||||
|
slash_commands?: string[];
|
||||||
|
apiKeySource?: string;
|
||||||
|
qwen_code_version?: string;
|
||||||
|
output_style?: string;
|
||||||
|
agents?: string[];
|
||||||
|
skills?: string[];
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
compact_metadata?: {
|
||||||
|
trigger: 'manual' | 'auto';
|
||||||
|
pre_tokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIResultMessageSuccess {
|
||||||
|
type: 'result';
|
||||||
|
subtype: 'success';
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
is_error: false;
|
||||||
|
duration_ms: number;
|
||||||
|
duration_api_ms: number;
|
||||||
|
num_turns: number;
|
||||||
|
result: string;
|
||||||
|
usage: ExtendedUsage;
|
||||||
|
modelUsage?: Record<string, ModelUsage>;
|
||||||
|
permission_denials: CLIPermissionDenial[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIResultMessageError {
|
||||||
|
type: 'result';
|
||||||
|
subtype: 'error_max_turns' | 'error_during_execution';
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
is_error: true;
|
||||||
|
duration_ms: number;
|
||||||
|
duration_api_ms: number;
|
||||||
|
num_turns: number;
|
||||||
|
usage: ExtendedUsage;
|
||||||
|
modelUsage?: Record<string, ModelUsage>;
|
||||||
|
permission_denials: CLIPermissionDenial[];
|
||||||
|
error?: {
|
||||||
|
type?: string;
|
||||||
|
message: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream event types for real-time message updates
|
||||||
|
*/
|
||||||
|
export interface MessageStartStreamEvent {
|
||||||
|
type: 'message_start';
|
||||||
|
message: {
|
||||||
|
id: string;
|
||||||
|
role: 'assistant';
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentBlockStartEvent {
|
||||||
|
type: 'content_block_start';
|
||||||
|
index: number;
|
||||||
|
content_block: ContentBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentBlockDelta =
|
||||||
|
| {
|
||||||
|
type: 'text_delta';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'thinking_delta';
|
||||||
|
thinking: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'input_json_delta';
|
||||||
|
partial_json: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ContentBlockDeltaEvent {
|
||||||
|
type: 'content_block_delta';
|
||||||
|
index: number;
|
||||||
|
delta: ContentBlockDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentBlockStopEvent {
|
||||||
|
type: 'content_block_stop';
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageStopStreamEvent {
|
||||||
|
type: 'message_stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamEvent =
|
||||||
|
| MessageStartStreamEvent
|
||||||
|
| ContentBlockStartEvent
|
||||||
|
| ContentBlockDeltaEvent
|
||||||
|
| ContentBlockStopEvent
|
||||||
|
| MessageStopStreamEvent;
|
||||||
|
|
||||||
|
export interface CLIPartialAssistantMessage {
|
||||||
|
type: 'stream_event';
|
||||||
|
uuid: string;
|
||||||
|
session_id: string;
|
||||||
|
event: StreamEvent;
|
||||||
|
parent_tool_use_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission suggestion for tool use requests
|
||||||
|
* TODO: Align with `ToolCallConfirmationDetails`
|
||||||
|
*/
|
||||||
|
export interface PermissionSuggestion {
|
||||||
|
type: 'allow' | 'deny' | 'modify';
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
modifiedInput?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook callback placeholder for future implementation
|
||||||
|
*/
|
||||||
|
export interface HookRegistration {
|
||||||
|
event: string;
|
||||||
|
callback_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook callback result placeholder for future implementation
|
||||||
|
*/
|
||||||
|
export interface HookCallbackResult {
|
||||||
|
shouldSkip?: boolean;
|
||||||
|
shouldInterrupt?: boolean;
|
||||||
|
suppressOutput?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlInterruptRequest {
|
||||||
|
subtype: 'interrupt';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlPermissionRequest {
|
||||||
|
subtype: 'can_use_tool';
|
||||||
|
tool_name: string;
|
||||||
|
tool_use_id: string;
|
||||||
|
input: unknown;
|
||||||
|
permission_suggestions: PermissionSuggestion[] | null;
|
||||||
|
blocked_path: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlInitializeRequest {
|
||||||
|
subtype: 'initialize';
|
||||||
|
hooks?: HookRegistration[] | null;
|
||||||
|
sdkMcpServers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlSetPermissionModeRequest {
|
||||||
|
subtype: 'set_permission_mode';
|
||||||
|
mode: PermissionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIHookCallbackRequest {
|
||||||
|
subtype: 'hook_callback';
|
||||||
|
callback_id: string;
|
||||||
|
input: unknown;
|
||||||
|
tool_use_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlMcpMessageRequest {
|
||||||
|
subtype: 'mcp_message';
|
||||||
|
server_name: string;
|
||||||
|
message: {
|
||||||
|
jsonrpc?: string;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
id?: string | number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlSetModelRequest {
|
||||||
|
subtype: 'set_model';
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlMcpStatusRequest {
|
||||||
|
subtype: 'mcp_server_status';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlSupportedCommandsRequest {
|
||||||
|
subtype: 'supported_commands';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlRequestPayload =
|
||||||
|
| CLIControlInterruptRequest
|
||||||
|
| CLIControlPermissionRequest
|
||||||
|
| CLIControlInitializeRequest
|
||||||
|
| CLIControlSetPermissionModeRequest
|
||||||
|
| CLIHookCallbackRequest
|
||||||
|
| CLIControlMcpMessageRequest
|
||||||
|
| CLIControlSetModelRequest
|
||||||
|
| CLIControlMcpStatusRequest
|
||||||
|
| CLIControlSupportedCommandsRequest;
|
||||||
|
|
||||||
|
export interface CLIControlRequest {
|
||||||
|
type: 'control_request';
|
||||||
|
request_id: string;
|
||||||
|
request: ControlRequestPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission approval result
|
||||||
|
*/
|
||||||
|
export interface PermissionApproval {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
modifiedInput?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlResponse {
|
||||||
|
subtype: 'success';
|
||||||
|
request_id: string;
|
||||||
|
response: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlErrorResponse {
|
||||||
|
subtype: 'error';
|
||||||
|
request_id: string;
|
||||||
|
error: string | { message: string; [key: string]: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CLIControlResponse {
|
||||||
|
type: 'control_response';
|
||||||
|
response: ControlResponse | ControlErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlCancelRequest {
|
||||||
|
type: 'control_cancel_request';
|
||||||
|
request_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlMessage =
|
||||||
|
| CLIControlRequest
|
||||||
|
| CLIControlResponse
|
||||||
|
| ControlCancelRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of all CLI message types
|
||||||
|
*/
|
||||||
|
export type CLIMessage =
|
||||||
|
| CLIUserMessage
|
||||||
|
| CLIAssistantMessage
|
||||||
|
| CLISystemMessage
|
||||||
|
| CLIResultMessage
|
||||||
|
| CLIPartialAssistantMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard functions for message discrimination
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isCLIUserMessage(msg: any): msg is CLIUserMessage {
|
||||||
|
return (
|
||||||
|
msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'assistant' &&
|
||||||
|
'uuid' in msg &&
|
||||||
|
'message' in msg &&
|
||||||
|
'session_id' in msg &&
|
||||||
|
'parent_tool_use_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCLISystemMessage(msg: any): msg is CLISystemMessage {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'system' &&
|
||||||
|
'subtype' in msg &&
|
||||||
|
'uuid' in msg &&
|
||||||
|
'session_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCLIResultMessage(msg: any): msg is CLIResultMessage {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'result' &&
|
||||||
|
'subtype' in msg &&
|
||||||
|
'duration_ms' in msg &&
|
||||||
|
'is_error' in msg &&
|
||||||
|
'uuid' in msg &&
|
||||||
|
'session_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCLIPartialAssistantMessage(
|
||||||
|
msg: any,
|
||||||
|
): msg is CLIPartialAssistantMessage {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'stream_event' &&
|
||||||
|
'uuid' in msg &&
|
||||||
|
'session_id' in msg &&
|
||||||
|
'event' in msg &&
|
||||||
|
'parent_tool_use_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isControlRequest(msg: any): msg is CLIControlRequest {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'control_request' &&
|
||||||
|
'request_id' in msg &&
|
||||||
|
'request' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isControlResponse(msg: any): msg is CLIControlResponse {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'control_response' &&
|
||||||
|
'response' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isControlCancel(msg: any): msg is ControlCancelRequest {
|
||||||
|
return (
|
||||||
|
msg &&
|
||||||
|
typeof msg === 'object' &&
|
||||||
|
msg.type === 'control_cancel_request' &&
|
||||||
|
'request_id' in msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content block type guards
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isTextBlock(block: any): block is TextBlock {
|
||||||
|
return block && typeof block === 'object' && block.type === 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isThinkingBlock(block: any): block is ThinkingBlock {
|
||||||
|
return block && typeof block === 'object' && block.type === 'thinking';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToolUseBlock(block: any): block is ToolUseBlock {
|
||||||
|
return block && typeof block === 'object' && block.type === 'tool_use';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToolResultBlock(block: any): block is ToolResultBlock {
|
||||||
|
return block && typeof block === 'object' && block.type === 'tool_result';
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,16 @@ import {
|
|||||||
FatalInputError,
|
FatalInputError,
|
||||||
promptIdContext,
|
promptIdContext,
|
||||||
OutputFormat,
|
OutputFormat,
|
||||||
JsonFormatter,
|
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||||
import type { Content, Part } from '@google/genai';
|
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||||
|
import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||||
|
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
|
||||||
|
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
||||||
|
import type { ControlService } from './nonInteractive/control/ControlService.js';
|
||||||
|
|
||||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
|
||||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||||
import {
|
import {
|
||||||
handleError,
|
handleError,
|
||||||
@@ -30,73 +32,144 @@ import {
|
|||||||
handleCancellationError,
|
handleCancellationError,
|
||||||
handleMaxTurnsExceededError,
|
handleMaxTurnsExceededError,
|
||||||
} from './utils/errors.js';
|
} from './utils/errors.js';
|
||||||
|
import {
|
||||||
|
normalizePartList,
|
||||||
|
extractPartsFromUserMessage,
|
||||||
|
buildSystemMessage,
|
||||||
|
createTaskToolProgressHandler,
|
||||||
|
computeUsageFromMetrics,
|
||||||
|
} from './utils/nonInteractiveHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides optional overrides for `runNonInteractive` execution.
|
||||||
|
*
|
||||||
|
* @param abortController - Optional abort controller for cancellation.
|
||||||
|
* @param adapter - Optional JSON output adapter for structured output formats.
|
||||||
|
* @param userMessage - Optional CLI user message payload for preformatted input.
|
||||||
|
* @param controlService - Optional control service for future permission handling.
|
||||||
|
*/
|
||||||
|
export interface RunNonInteractiveOptions {
|
||||||
|
abortController?: AbortController;
|
||||||
|
adapter?: JsonOutputAdapterInterface;
|
||||||
|
userMessage?: CLIUserMessage;
|
||||||
|
controlService?: ControlService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the non-interactive CLI flow for a single request.
|
||||||
|
*/
|
||||||
export async function runNonInteractive(
|
export async function runNonInteractive(
|
||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
input: string,
|
input: string,
|
||||||
prompt_id: string,
|
prompt_id: string,
|
||||||
|
options: RunNonInteractiveOptions = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return promptIdContext.run(prompt_id, async () => {
|
return promptIdContext.run(prompt_id, async () => {
|
||||||
const consolePatcher = new ConsolePatcher({
|
// Create output adapter based on format
|
||||||
stderr: true,
|
let adapter: JsonOutputAdapterInterface | undefined;
|
||||||
debugMode: config.getDebugMode(),
|
const outputFormat = config.getOutputFormat();
|
||||||
});
|
|
||||||
|
if (options.adapter) {
|
||||||
|
adapter = options.adapter;
|
||||||
|
} else if (outputFormat === OutputFormat.JSON) {
|
||||||
|
adapter = new JsonOutputAdapter(config);
|
||||||
|
} else if (outputFormat === OutputFormat.STREAM_JSON) {
|
||||||
|
adapter = new StreamJsonOutputAdapter(
|
||||||
|
config,
|
||||||
|
config.getIncludePartialMessages(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get readonly values once at the start
|
||||||
|
const sessionId = config.getSessionId();
|
||||||
|
const permissionMode = config.getApprovalMode() as PermissionMode;
|
||||||
|
|
||||||
|
let turnCount = 0;
|
||||||
|
let totalApiDurationMs = 0;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === 'EPIPE') {
|
||||||
|
process.stdout.removeListener('error', stdoutErrorHandler);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const geminiClient = config.getGeminiClient();
|
||||||
|
const abortController = options.abortController ?? new AbortController();
|
||||||
|
|
||||||
|
// Setup signal handlers for graceful shutdown
|
||||||
|
const shutdownHandler = () => {
|
||||||
|
if (config.getDebugMode()) {
|
||||||
|
console.error('[runNonInteractive] Shutdown signal received');
|
||||||
|
}
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
consolePatcher.patch();
|
process.stdout.on('error', stdoutErrorHandler);
|
||||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
|
||||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
|
||||||
if (err.code === 'EPIPE') {
|
|
||||||
// Exit gracefully if the pipe is closed.
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const geminiClient = config.getGeminiClient();
|
process.on('SIGINT', shutdownHandler);
|
||||||
|
process.on('SIGTERM', shutdownHandler);
|
||||||
|
|
||||||
const abortController = new AbortController();
|
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
||||||
|
options.userMessage,
|
||||||
|
);
|
||||||
|
|
||||||
let query: Part[] | undefined;
|
if (!initialPartList) {
|
||||||
|
let slashHandled = false;
|
||||||
if (isSlashCommand(input)) {
|
if (isSlashCommand(input)) {
|
||||||
const slashCommandResult = await handleSlashCommand(
|
const slashCommandResult = await handleSlashCommand(
|
||||||
input,
|
input,
|
||||||
abortController,
|
abortController,
|
||||||
config,
|
config,
|
||||||
settings,
|
settings,
|
||||||
);
|
|
||||||
// If a slash command is found and returns a prompt, use it.
|
|
||||||
// Otherwise, slashCommandResult fall through to the default prompt
|
|
||||||
// handling.
|
|
||||||
if (slashCommandResult) {
|
|
||||||
query = slashCommandResult as Part[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
const { processedQuery, shouldProceed } = await handleAtCommand({
|
|
||||||
query: input,
|
|
||||||
config,
|
|
||||||
addItem: (_item, _timestamp) => 0,
|
|
||||||
onDebugMessage: () => {},
|
|
||||||
messageId: Date.now(),
|
|
||||||
signal: abortController.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!shouldProceed || !processedQuery) {
|
|
||||||
// An error occurred during @include processing (e.g., file not found).
|
|
||||||
// The error message is already logged by handleAtCommand.
|
|
||||||
throw new FatalInputError(
|
|
||||||
'Exiting due to an error processing the @ command.',
|
|
||||||
);
|
);
|
||||||
|
if (slashCommandResult) {
|
||||||
|
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||||
|
initialPartList = slashCommandResult as PartListUnion;
|
||||||
|
slashHandled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slashHandled) {
|
||||||
|
const { processedQuery, shouldProceed } = await handleAtCommand({
|
||||||
|
query: input,
|
||||||
|
config,
|
||||||
|
addItem: (_item, _timestamp) => 0,
|
||||||
|
onDebugMessage: () => {},
|
||||||
|
messageId: Date.now(),
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldProceed || !processedQuery) {
|
||||||
|
// An error occurred during @include processing (e.g., file not found).
|
||||||
|
// The error message is already logged by handleAtCommand.
|
||||||
|
throw new FatalInputError(
|
||||||
|
'Exiting due to an error processing the @ command.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
initialPartList = processedQuery as PartListUnion;
|
||||||
}
|
}
|
||||||
query = processedQuery as Part[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
if (!initialPartList) {
|
||||||
|
initialPartList = [{ text: input }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialParts = normalizePartList(initialPartList);
|
||||||
|
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
||||||
|
|
||||||
|
if (adapter) {
|
||||||
|
const systemMessage = await buildSystemMessage(
|
||||||
|
config,
|
||||||
|
sessionId,
|
||||||
|
permissionMode,
|
||||||
|
);
|
||||||
|
adapter.emitMessage(systemMessage);
|
||||||
|
}
|
||||||
|
|
||||||
let turnCount = 0;
|
|
||||||
while (true) {
|
while (true) {
|
||||||
turnCount++;
|
turnCount++;
|
||||||
if (
|
if (
|
||||||
@@ -105,43 +178,124 @@ export async function runNonInteractive(
|
|||||||
) {
|
) {
|
||||||
handleMaxTurnsExceededError(config);
|
handleMaxTurnsExceededError(config);
|
||||||
}
|
}
|
||||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
|
||||||
|
|
||||||
|
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||||
|
const apiStartTime = Date.now();
|
||||||
const responseStream = geminiClient.sendMessageStream(
|
const responseStream = geminiClient.sendMessageStream(
|
||||||
currentMessages[0]?.parts || [],
|
currentMessages[0]?.parts || [],
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
prompt_id,
|
prompt_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
let responseText = '';
|
// Start assistant message for this turn
|
||||||
|
if (adapter) {
|
||||||
|
adapter.startAssistantMessage();
|
||||||
|
}
|
||||||
|
|
||||||
for await (const event of responseStream) {
|
for await (const event of responseStream) {
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
handleCancellationError(config);
|
handleCancellationError(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === GeminiEventType.Content) {
|
if (adapter) {
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
// Use adapter for all event processing
|
||||||
responseText += event.value;
|
adapter.processEvent(event);
|
||||||
} else {
|
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||||
process.stdout.write(event.value);
|
toolCallRequests.push(event.value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Text output mode - direct stdout
|
||||||
|
if (event.type === GeminiEventType.Content) {
|
||||||
|
process.stdout.write(event.value);
|
||||||
|
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||||
|
toolCallRequests.push(event.value);
|
||||||
}
|
}
|
||||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
|
||||||
toolCallRequests.push(event.value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finalize assistant message
|
||||||
|
if (adapter) {
|
||||||
|
adapter.finalizeAssistantMessage();
|
||||||
|
}
|
||||||
|
totalApiDurationMs += Date.now() - apiStartTime;
|
||||||
|
|
||||||
if (toolCallRequests.length > 0) {
|
if (toolCallRequests.length > 0) {
|
||||||
const toolResponseParts: Part[] = [];
|
const toolResponseParts: Part[] = [];
|
||||||
|
|
||||||
for (const requestInfo of toolCallRequests) {
|
for (const requestInfo of toolCallRequests) {
|
||||||
|
const finalRequestInfo = requestInfo;
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (options.controlService) {
|
||||||
|
const permissionResult =
|
||||||
|
await options.controlService.permission.shouldAllowTool(
|
||||||
|
requestInfo,
|
||||||
|
);
|
||||||
|
if (!permissionResult.allowed) {
|
||||||
|
if (config.getDebugMode()) {
|
||||||
|
console.error(
|
||||||
|
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
|
||||||
|
permissionResult.message ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (adapter && permissionResult.message) {
|
||||||
|
adapter.emitSystemMessage('tool_denied', {
|
||||||
|
tool: requestInfo.name,
|
||||||
|
message: permissionResult.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionResult.updatedArgs) {
|
||||||
|
finalRequestInfo = {
|
||||||
|
...requestInfo,
|
||||||
|
args: permissionResult.updatedArgs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCallUpdateCallback = options.controlService
|
||||||
|
? options.controlService.permission.getToolCallUpdateCallback()
|
||||||
|
: undefined;
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Only pass outputUpdateHandler for Task tool
|
||||||
|
const isTaskTool = finalRequestInfo.name === 'task';
|
||||||
|
const taskToolProgress = isTaskTool
|
||||||
|
? createTaskToolProgressHandler(
|
||||||
|
config,
|
||||||
|
finalRequestInfo.callId,
|
||||||
|
adapter,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const taskToolProgressHandler = taskToolProgress?.handler;
|
||||||
const toolResponse = await executeToolCall(
|
const toolResponse = await executeToolCall(
|
||||||
config,
|
config,
|
||||||
requestInfo,
|
finalRequestInfo,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
|
isTaskTool && taskToolProgressHandler
|
||||||
|
? {
|
||||||
|
outputUpdateHandler: taskToolProgressHandler,
|
||||||
|
/*
|
||||||
|
toolCallUpdateCallback
|
||||||
|
? { onToolCallsUpdate: toolCallUpdateCallback }
|
||||||
|
: undefined,
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||||
|
// adapter's messages array and will be output together on emitResult()
|
||||||
|
|
||||||
if (toolResponse.error) {
|
if (toolResponse.error) {
|
||||||
|
// In JSON/STREAM_JSON mode, tool errors are tolerated and formatted
|
||||||
|
// as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode
|
||||||
|
// from config and allow the session to continue so the LLM can decide what to do next.
|
||||||
|
// In text mode, we still log the error.
|
||||||
handleToolError(
|
handleToolError(
|
||||||
requestInfo.name,
|
finalRequestInfo.name,
|
||||||
toolResponse.error,
|
toolResponse.error,
|
||||||
config,
|
config,
|
||||||
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||||
@@ -149,6 +303,13 @@ export async function runNonInteractive(
|
|||||||
? toolResponse.resultDisplay
|
? toolResponse.resultDisplay
|
||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
// Note: We no longer emit a separate system message for tool errors
|
||||||
|
// in JSON/STREAM_JSON mode, as the error is already captured in the
|
||||||
|
// tool_result block with is_error=true.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adapter) {
|
||||||
|
adapter.emitToolResult(finalRequestInfo, toolResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolResponse.responseParts) {
|
if (toolResponse.responseParts) {
|
||||||
@@ -157,20 +318,57 @@ export async function runNonInteractive(
|
|||||||
}
|
}
|
||||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||||
} else {
|
} else {
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
// For JSON and STREAM_JSON modes, compute usage from metrics
|
||||||
const formatter = new JsonFormatter();
|
if (adapter) {
|
||||||
const stats = uiTelemetryService.getMetrics();
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
process.stdout.write(formatter.format(responseText, stats));
|
const usage = computeUsageFromMetrics(metrics);
|
||||||
|
// Get stats for JSON format output
|
||||||
|
const stats =
|
||||||
|
outputFormat === OutputFormat.JSON
|
||||||
|
? uiTelemetryService.getMetrics()
|
||||||
|
: undefined;
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: false,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
apiDurationMs: totalApiDurationMs,
|
||||||
|
numTurns: turnCount,
|
||||||
|
usage,
|
||||||
|
stats,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write('\n'); // Ensure a final newline
|
// Text output mode - no usage needed
|
||||||
|
process.stdout.write('\n');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// For JSON and STREAM_JSON modes, compute usage from metrics
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (adapter) {
|
||||||
|
const metrics = uiTelemetryService.getMetrics();
|
||||||
|
const usage = computeUsageFromMetrics(metrics);
|
||||||
|
// Get stats for JSON format output
|
||||||
|
const stats =
|
||||||
|
outputFormat === OutputFormat.JSON
|
||||||
|
? uiTelemetryService.getMetrics()
|
||||||
|
: undefined;
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: true,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
apiDurationMs: totalApiDurationMs,
|
||||||
|
numTurns: turnCount,
|
||||||
|
errorMessage: message,
|
||||||
|
usage,
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
}
|
||||||
handleError(error, config);
|
handleError(error, config);
|
||||||
} finally {
|
} finally {
|
||||||
consolePatcher.cleanup();
|
process.stdout.removeListener('error', stdoutErrorHandler);
|
||||||
|
// Cleanup signal handlers
|
||||||
|
process.removeListener('SIGINT', shutdownHandler);
|
||||||
|
process.removeListener('SIGTERM', shutdownHandler);
|
||||||
if (isTelemetrySdkInitialized()) {
|
if (isTelemetrySdkInitialized()) {
|
||||||
await shutdownTelemetry(config);
|
await shutdownTelemetry(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { vi, type MockInstance } from 'vitest';
|
import { vi, type Mock, type MockInstance } from 'vitest';
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +83,7 @@ describe('errors', () => {
|
|||||||
mockConfig = {
|
mockConfig = {
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
||||||
|
getDebugMode: vi.fn().mockReturnValue(true),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -254,105 +255,81 @@ describe('errors', () => {
|
|||||||
const toolName = 'test-tool';
|
const toolName = 'test-tool';
|
||||||
const toolError = new Error('Tool failed');
|
const toolError = new Error('Tool failed');
|
||||||
|
|
||||||
describe('in text mode', () => {
|
describe('when debug mode is enabled', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(
|
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
||||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
||||||
).mockReturnValue(OutputFormat.TEXT);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log error message to stderr', () => {
|
describe('in text mode', () => {
|
||||||
handleToolError(toolName, toolError, mockConfig);
|
beforeEach(() => {
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.TEXT);
|
||||||
|
});
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
it('should log error message to stderr and not exit', () => {
|
||||||
'Error executing tool test-tool: Tool failed',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use resultDisplay when provided', () => {
|
|
||||||
handleToolError(
|
|
||||||
toolName,
|
|
||||||
toolError,
|
|
||||||
mockConfig,
|
|
||||||
'CUSTOM_ERROR',
|
|
||||||
'Custom display message',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
'Error executing tool test-tool: Custom display message',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('in JSON mode', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
(
|
|
||||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
||||||
).mockReturnValue(OutputFormat.JSON);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should format error as JSON and exit with default code', () => {
|
|
||||||
expect(() => {
|
|
||||||
handleToolError(toolName, toolError, mockConfig);
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
}).toThrow('process.exit called with code: 54');
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
JSON.stringify(
|
'Error executing tool test-tool: Tool failed',
|
||||||
{
|
);
|
||||||
error: {
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
type: 'FatalToolExecutionError',
|
});
|
||||||
message: 'Error executing tool test-tool: Tool failed',
|
|
||||||
code: 54,
|
it('should use resultDisplay when provided and not exit', () => {
|
||||||
},
|
handleToolError(
|
||||||
},
|
toolName,
|
||||||
null,
|
toolError,
|
||||||
2,
|
mockConfig,
|
||||||
),
|
'CUSTOM_ERROR',
|
||||||
);
|
'Custom display message',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error executing tool test-tool: Custom display message',
|
||||||
|
);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use custom error code', () => {
|
describe('in JSON mode', () => {
|
||||||
expect(() => {
|
beforeEach(() => {
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.JSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log error message to stderr and not exit', () => {
|
||||||
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error executing tool test-tool: Tool failed',
|
||||||
|
);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log error with custom error code and not exit', () => {
|
||||||
handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR');
|
handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR');
|
||||||
}).toThrow('process.exit called with code: 54');
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||||
JSON.stringify(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
{
|
'Error executing tool test-tool: Tool failed',
|
||||||
error: {
|
);
|
||||||
type: 'FatalToolExecutionError',
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
message: 'Error executing tool test-tool: Tool failed',
|
});
|
||||||
code: 'CUSTOM_TOOL_ERROR',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use numeric error code and exit with that code', () => {
|
it('should log error with numeric error code and not exit', () => {
|
||||||
expect(() => {
|
|
||||||
handleToolError(toolName, toolError, mockConfig, 500);
|
handleToolError(toolName, toolError, mockConfig, 500);
|
||||||
}).toThrow('process.exit called with code: 500');
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||||
JSON.stringify(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
{
|
'Error executing tool test-tool: Tool failed',
|
||||||
error: {
|
);
|
||||||
type: 'FatalToolExecutionError',
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
message: 'Error executing tool test-tool: Tool failed',
|
});
|
||||||
code: 500,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prefer resultDisplay over error message', () => {
|
it('should prefer resultDisplay over error message and not exit', () => {
|
||||||
expect(() => {
|
|
||||||
handleToolError(
|
handleToolError(
|
||||||
toolName,
|
toolName,
|
||||||
toolError,
|
toolError,
|
||||||
@@ -360,21 +337,99 @@ describe('errors', () => {
|
|||||||
'DISPLAY_ERROR',
|
'DISPLAY_ERROR',
|
||||||
'Display message',
|
'Display message',
|
||||||
);
|
);
|
||||||
}).toThrow('process.exit called with code: 54');
|
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||||
JSON.stringify(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
{
|
'Error executing tool test-tool: Display message',
|
||||||
error: {
|
);
|
||||||
type: 'FatalToolExecutionError',
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
message: 'Error executing tool test-tool: Display message',
|
});
|
||||||
code: 'DISPLAY_ERROR',
|
});
|
||||||
},
|
|
||||||
},
|
describe('in STREAM_JSON mode', () => {
|
||||||
null,
|
beforeEach(() => {
|
||||||
2,
|
(
|
||||||
),
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
);
|
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log error message to stderr and not exit', () => {
|
||||||
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
|
// Should not exit in STREAM_JSON mode (just log to stderr when debug mode is on)
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error executing tool test-tool: Tool failed',
|
||||||
|
);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when debug mode is disabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log and not exit in text mode', () => {
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.TEXT);
|
||||||
|
|
||||||
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log and not exit in JSON mode', () => {
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.JSON);
|
||||||
|
|
||||||
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log and not exit in STREAM_JSON mode', () => {
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||||
|
|
||||||
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('process exit behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should never exit regardless of output format', () => {
|
||||||
|
// Test in TEXT mode
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.TEXT);
|
||||||
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Test in JSON mode
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.JSON);
|
||||||
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Test in STREAM_JSON mode
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||||
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
JsonFormatter,
|
JsonFormatter,
|
||||||
parseAndFormatApiError,
|
parseAndFormatApiError,
|
||||||
FatalTurnLimitedError,
|
FatalTurnLimitedError,
|
||||||
FatalToolExecutionError,
|
|
||||||
FatalCancellationError,
|
FatalCancellationError,
|
||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
|
||||||
@@ -88,32 +87,29 @@ export function handleError(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles tool execution errors specifically.
|
* Handles tool execution errors specifically.
|
||||||
* In JSON mode, outputs formatted JSON error and exits.
|
* In JSON/STREAM_JSON mode, outputs error message to stderr only and does not exit.
|
||||||
|
* The error will be properly formatted in the tool_result block by the adapter,
|
||||||
|
* allowing the session to continue so the LLM can decide what to do next.
|
||||||
* In text mode, outputs error message to stderr only.
|
* In text mode, outputs error message to stderr only.
|
||||||
|
*
|
||||||
|
* @param toolName - Name of the tool that failed
|
||||||
|
* @param toolError - The error that occurred during tool execution
|
||||||
|
* @param config - Configuration object
|
||||||
|
* @param errorCode - Optional error code
|
||||||
|
* @param resultDisplay - Optional display message for the error
|
||||||
*/
|
*/
|
||||||
export function handleToolError(
|
export function handleToolError(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
toolError: Error,
|
toolError: Error,
|
||||||
config: Config,
|
config: Config,
|
||||||
errorCode?: string | number,
|
_errorCode?: string | number,
|
||||||
resultDisplay?: string,
|
resultDisplay?: string,
|
||||||
): void {
|
): void {
|
||||||
const errorMessage = `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`;
|
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
|
||||||
const toolExecutionError = new FatalToolExecutionError(errorMessage);
|
if (config.getDebugMode()) {
|
||||||
|
console.error(
|
||||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
||||||
const formatter = new JsonFormatter();
|
|
||||||
const formattedError = formatter.formatError(
|
|
||||||
toolExecutionError,
|
|
||||||
errorCode ?? toolExecutionError.exitCode,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
console.error(formattedError);
|
|
||||||
process.exit(
|
|
||||||
typeof errorCode === 'number' ? errorCode : toolExecutionError.exitCode,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(errorMessage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1168
packages/cli/src/utils/nonInteractiveHelpers.test.ts
Normal file
1168
packages/cli/src/utils/nonInteractiveHelpers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
624
packages/cli/src/utils/nonInteractiveHelpers.ts
Normal file
624
packages/cli/src/utils/nonInteractiveHelpers.ts
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Config,
|
||||||
|
ToolResultDisplay,
|
||||||
|
TaskResultDisplay,
|
||||||
|
OutputUpdateHandler,
|
||||||
|
ToolCallRequestInfo,
|
||||||
|
ToolCallResponseInfo,
|
||||||
|
SessionMetrics,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import {
|
||||||
|
OutputFormat,
|
||||||
|
ToolErrorType,
|
||||||
|
getMCPServerStatus,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import type { Part, PartListUnion } from '@google/genai';
|
||||||
|
import type {
|
||||||
|
CLIUserMessage,
|
||||||
|
Usage,
|
||||||
|
PermissionMode,
|
||||||
|
CLISystemMessage,
|
||||||
|
} from '../nonInteractive/types.js';
|
||||||
|
import { CommandService } from '../services/CommandService.js';
|
||||||
|
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
|
||||||
|
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||||
|
import { computeSessionStats } from '../ui/utils/computeStats.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes various part list formats into a consistent Part[] array.
|
||||||
|
*
|
||||||
|
* @param parts - Input parts in various formats (string, Part, Part[], or null)
|
||||||
|
* @returns Normalized array of Part objects
|
||||||
|
*/
|
||||||
|
export function normalizePartList(parts: PartListUnion | null): Part[] {
|
||||||
|
if (!parts) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof parts === 'string') {
|
||||||
|
return [{ text: parts }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parts)) {
|
||||||
|
return parts.map((part) =>
|
||||||
|
typeof part === 'string' ? { text: part } : (part as Part),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [parts as Part];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts user message parts from a CLI protocol message.
|
||||||
|
*
|
||||||
|
* @param message - User message sourced from the CLI protocol layer
|
||||||
|
* @returns Extracted parts or null if the message lacks textual content
|
||||||
|
*/
|
||||||
|
export function extractPartsFromUserMessage(
|
||||||
|
message: CLIUserMessage | undefined,
|
||||||
|
): PartListUnion | null {
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = message.message?.content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const parts: Part[] = [];
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== 'object' || !('type' in block)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block.type === 'text' && 'text' in block && block.text) {
|
||||||
|
parts.push({ text: block.text });
|
||||||
|
} else {
|
||||||
|
parts.push({ text: JSON.stringify(block) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts usage metadata from the Gemini client's debug responses.
|
||||||
|
*
|
||||||
|
* @param geminiClient - The Gemini client instance
|
||||||
|
* @returns Usage information or undefined if not available
|
||||||
|
*/
|
||||||
|
export function extractUsageFromGeminiClient(
|
||||||
|
geminiClient: unknown,
|
||||||
|
): Usage | undefined {
|
||||||
|
if (
|
||||||
|
!geminiClient ||
|
||||||
|
typeof geminiClient !== 'object' ||
|
||||||
|
typeof (geminiClient as { getChat?: unknown }).getChat !== 'function'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chat = (geminiClient as { getChat: () => unknown }).getChat();
|
||||||
|
if (
|
||||||
|
!chat ||
|
||||||
|
typeof chat !== 'object' ||
|
||||||
|
typeof (chat as { getDebugResponses?: unknown }).getDebugResponses !==
|
||||||
|
'function'
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = (
|
||||||
|
chat as {
|
||||||
|
getDebugResponses: () => Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
).getDebugResponses();
|
||||||
|
for (let i = responses.length - 1; i >= 0; i--) {
|
||||||
|
const metadata = responses[i]?.['usageMetadata'] as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (metadata) {
|
||||||
|
const promptTokens = metadata['promptTokenCount'];
|
||||||
|
const completionTokens = metadata['candidatesTokenCount'];
|
||||||
|
const totalTokens = metadata['totalTokenCount'];
|
||||||
|
const cachedTokens = metadata['cachedContentTokenCount'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
input_tokens: typeof promptTokens === 'number' ? promptTokens : 0,
|
||||||
|
output_tokens:
|
||||||
|
typeof completionTokens === 'number' ? completionTokens : 0,
|
||||||
|
total_tokens:
|
||||||
|
typeof totalTokens === 'number' ? totalTokens : undefined,
|
||||||
|
cache_read_input_tokens:
|
||||||
|
typeof cachedTokens === 'number' ? cachedTokens : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Failed to extract usage metadata:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes Usage information from SessionMetrics using computeSessionStats.
|
||||||
|
* Aggregates token usage across all models in the session.
|
||||||
|
*
|
||||||
|
* @param metrics - Session metrics from uiTelemetryService
|
||||||
|
* @returns Usage object with token counts
|
||||||
|
*/
|
||||||
|
export function computeUsageFromMetrics(metrics: SessionMetrics): Usage {
|
||||||
|
const stats = computeSessionStats(metrics);
|
||||||
|
const { models } = metrics;
|
||||||
|
|
||||||
|
// Sum up output tokens (candidates) and total tokens across all models
|
||||||
|
const totalOutputTokens = Object.values(models).reduce(
|
||||||
|
(acc, model) => acc + model.tokens.candidates,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalTokens = Object.values(models).reduce(
|
||||||
|
(acc, model) => acc + model.tokens.total,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const usage: Usage = {
|
||||||
|
input_tokens: stats.totalPromptTokens,
|
||||||
|
output_tokens: totalOutputTokens,
|
||||||
|
cache_read_input_tokens: stats.totalCachedTokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include total_tokens if it's greater than 0
|
||||||
|
if (totalTokens > 0) {
|
||||||
|
usage.total_tokens = totalTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load slash command names using CommandService
|
||||||
|
*
|
||||||
|
* @param config - Config instance
|
||||||
|
* @returns Promise resolving to array of slash command names
|
||||||
|
*/
|
||||||
|
async function loadSlashCommandNames(config: Config): Promise<string[]> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
try {
|
||||||
|
const service = await CommandService.create(
|
||||||
|
[new BuiltinCommandLoader(config)],
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
const names = new Set<string>();
|
||||||
|
const commands = service.getCommands();
|
||||||
|
for (const command of commands) {
|
||||||
|
names.add(command.name);
|
||||||
|
}
|
||||||
|
return Array.from(names).sort();
|
||||||
|
} catch (error) {
|
||||||
|
if (config.getDebugMode()) {
|
||||||
|
console.error(
|
||||||
|
'[buildSystemMessage] Failed to load slash commands:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build system message for SDK
|
||||||
|
*
|
||||||
|
* Constructs a system initialization message including tools, MCP servers,
|
||||||
|
* and model configuration. System messages are independent of the control
|
||||||
|
* system and are sent before every turn regardless of whether control
|
||||||
|
* system is available.
|
||||||
|
*
|
||||||
|
* Note: Control capabilities are NOT included in system messages. They
|
||||||
|
* are only included in the initialize control response, which is handled
|
||||||
|
* separately by SystemController.
|
||||||
|
*
|
||||||
|
* @param config - Config instance
|
||||||
|
* @param sessionId - Session identifier
|
||||||
|
* @param permissionMode - Current permission/approval mode
|
||||||
|
* @returns Promise resolving to CLISystemMessage
|
||||||
|
*/
|
||||||
|
export async function buildSystemMessage(
|
||||||
|
config: Config,
|
||||||
|
sessionId: string,
|
||||||
|
permissionMode: PermissionMode,
|
||||||
|
): Promise<CLISystemMessage> {
|
||||||
|
const toolRegistry = config.getToolRegistry();
|
||||||
|
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
|
||||||
|
|
||||||
|
const mcpServers = config.getMcpServers();
|
||||||
|
const mcpServerList = mcpServers
|
||||||
|
? Object.keys(mcpServers).map((name) => ({
|
||||||
|
name,
|
||||||
|
status: getMCPServerStatus(name),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Load slash commands
|
||||||
|
const slashCommands = await loadSlashCommandNames(config);
|
||||||
|
|
||||||
|
// Load subagent names from config
|
||||||
|
let agentNames: string[] = [];
|
||||||
|
try {
|
||||||
|
const subagentManager = config.getSubagentManager();
|
||||||
|
const subagents = await subagentManager.listSubagents();
|
||||||
|
agentNames = subagents.map((subagent) => subagent.name);
|
||||||
|
} catch (error) {
|
||||||
|
if (config.getDebugMode()) {
|
||||||
|
console.error('[buildSystemMessage] Failed to load subagents:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemMessage: CLISystemMessage = {
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'init',
|
||||||
|
uuid: sessionId,
|
||||||
|
session_id: sessionId,
|
||||||
|
cwd: config.getTargetDir(),
|
||||||
|
tools,
|
||||||
|
mcp_servers: mcpServerList,
|
||||||
|
model: config.getModel(),
|
||||||
|
permissionMode,
|
||||||
|
slash_commands: slashCommands,
|
||||||
|
qwen_code_version: config.getCliVersion() || 'unknown',
|
||||||
|
agents: agentNames,
|
||||||
|
};
|
||||||
|
|
||||||
|
return systemMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an output update handler specifically for Task tool subagent execution.
|
||||||
|
* This handler monitors TaskResultDisplay updates and converts them to protocol messages
|
||||||
|
* using the unified adapter's subagent APIs. All emitted messages will have parent_tool_use_id set to
|
||||||
|
* the task tool's callId.
|
||||||
|
*
|
||||||
|
* @param config - Config instance for getting output format
|
||||||
|
* @param taskToolCallId - The task tool's callId to use as parent_tool_use_id for all subagent messages
|
||||||
|
* @param adapter - The unified adapter instance (JsonOutputAdapter or StreamJsonOutputAdapter)
|
||||||
|
* @returns An object containing the output update handler
|
||||||
|
*/
|
||||||
|
export function createTaskToolProgressHandler(
|
||||||
|
config: Config,
|
||||||
|
taskToolCallId: string,
|
||||||
|
adapter: JsonOutputAdapterInterface | undefined,
|
||||||
|
): {
|
||||||
|
handler: OutputUpdateHandler;
|
||||||
|
} {
|
||||||
|
// Track previous TaskResultDisplay states per tool call to detect changes
|
||||||
|
const previousTaskStates = new Map<string, TaskResultDisplay>();
|
||||||
|
// Track which tool call IDs have already emitted tool_use to prevent duplicates
|
||||||
|
const emittedToolUseIds = new Set<string>();
|
||||||
|
// Track which tool call IDs have already emitted tool_result to prevent duplicates
|
||||||
|
const emittedToolResultIds = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a ToolCallRequestInfo object from a tool call.
|
||||||
|
*
|
||||||
|
* @param toolCall - The tool call information
|
||||||
|
* @returns ToolCallRequestInfo object
|
||||||
|
*/
|
||||||
|
const buildRequest = (
|
||||||
|
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||||
|
): ToolCallRequestInfo => ({
|
||||||
|
callId: toolCall.callId,
|
||||||
|
name: toolCall.name,
|
||||||
|
args: toolCall.args || {},
|
||||||
|
isClientInitiated: true,
|
||||||
|
prompt_id: '',
|
||||||
|
response_id: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a ToolCallResponseInfo object from a tool call.
|
||||||
|
*
|
||||||
|
* @param toolCall - The tool call information
|
||||||
|
* @returns ToolCallResponseInfo object
|
||||||
|
*/
|
||||||
|
const buildResponse = (
|
||||||
|
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||||
|
): ToolCallResponseInfo => ({
|
||||||
|
callId: toolCall.callId,
|
||||||
|
error:
|
||||||
|
toolCall.status === 'failed'
|
||||||
|
? new Error(toolCall.error || 'Tool execution failed')
|
||||||
|
: undefined,
|
||||||
|
errorType:
|
||||||
|
toolCall.status === 'failed' ? ToolErrorType.EXECUTION_FAILED : undefined,
|
||||||
|
resultDisplay: toolCall.resultDisplay,
|
||||||
|
responseParts: toolCall.responseParts || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a tool call has result content that should be emitted.
|
||||||
|
*
|
||||||
|
* @param toolCall - The tool call information
|
||||||
|
* @returns True if the tool call has result content to emit
|
||||||
|
*/
|
||||||
|
const hasResultContent = (
|
||||||
|
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||||
|
): boolean => {
|
||||||
|
// Check resultDisplay string
|
||||||
|
if (
|
||||||
|
typeof toolCall.resultDisplay === 'string' &&
|
||||||
|
toolCall.resultDisplay.trim().length > 0
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check responseParts - only check existence, don't parse for performance
|
||||||
|
if (toolCall.responseParts && toolCall.responseParts.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed status should always emit result
|
||||||
|
return toolCall.status === 'failed';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits tool_use for a tool call if it hasn't been emitted yet.
|
||||||
|
*
|
||||||
|
* @param toolCall - The tool call information
|
||||||
|
* @param fallbackStatus - Optional fallback status if toolCall.status should be overridden
|
||||||
|
*/
|
||||||
|
const emitToolUseIfNeeded = (
|
||||||
|
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||||
|
fallbackStatus?: 'executing' | 'awaiting_approval',
|
||||||
|
): void => {
|
||||||
|
if (emittedToolUseIds.has(toolCall.callId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCallToEmit: NonNullable<TaskResultDisplay['toolCalls']>[number] =
|
||||||
|
fallbackStatus
|
||||||
|
? {
|
||||||
|
...toolCall,
|
||||||
|
status: fallbackStatus,
|
||||||
|
}
|
||||||
|
: toolCall;
|
||||||
|
|
||||||
|
if (
|
||||||
|
toolCallToEmit.status === 'executing' ||
|
||||||
|
toolCallToEmit.status === 'awaiting_approval'
|
||||||
|
) {
|
||||||
|
if (adapter?.processSubagentToolCall) {
|
||||||
|
adapter.processSubagentToolCall(toolCallToEmit, taskToolCallId);
|
||||||
|
emittedToolUseIds.add(toolCall.callId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits tool_result for a tool call if it hasn't been emitted yet and has content.
|
||||||
|
*
|
||||||
|
* @param toolCall - The tool call information
|
||||||
|
*/
|
||||||
|
const emitToolResultIfNeeded = (
|
||||||
|
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||||
|
): void => {
|
||||||
|
if (emittedToolResultIds.has(toolCall.callId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasResultContent(toolCall)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as emitted even if we skip, to prevent duplicate emits
|
||||||
|
emittedToolResultIds.add(toolCall.callId);
|
||||||
|
|
||||||
|
if (adapter) {
|
||||||
|
const request = buildRequest(toolCall);
|
||||||
|
const response = buildResponse(toolCall);
|
||||||
|
// For subagent tool results, we need to pass parentToolUseId
|
||||||
|
// The adapter implementations accept an optional parentToolUseId parameter
|
||||||
|
if (
|
||||||
|
'emitToolResult' in adapter &&
|
||||||
|
typeof adapter.emitToolResult === 'function'
|
||||||
|
) {
|
||||||
|
adapter.emitToolResult(request, response, taskToolCallId);
|
||||||
|
} else {
|
||||||
|
adapter.emitToolResult(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a tool call, ensuring tool_use and tool_result are emitted exactly once.
|
||||||
|
*
|
||||||
|
* @param toolCall - The tool call information
|
||||||
|
* @param previousCall - The previous state of the tool call (if any)
|
||||||
|
*/
|
||||||
|
const processToolCall = (
|
||||||
|
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||||
|
previousCall?: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||||
|
): void => {
|
||||||
|
const isCompleted =
|
||||||
|
toolCall.status === 'success' || toolCall.status === 'failed';
|
||||||
|
const isExecuting =
|
||||||
|
toolCall.status === 'executing' ||
|
||||||
|
toolCall.status === 'awaiting_approval';
|
||||||
|
const wasExecuting =
|
||||||
|
previousCall &&
|
||||||
|
(previousCall.status === 'executing' ||
|
||||||
|
previousCall.status === 'awaiting_approval');
|
||||||
|
|
||||||
|
// Emit tool_use if needed
|
||||||
|
if (isExecuting) {
|
||||||
|
// Normal case: tool call is executing or awaiting approval
|
||||||
|
emitToolUseIfNeeded(toolCall);
|
||||||
|
} else if (isCompleted && !emittedToolUseIds.has(toolCall.callId)) {
|
||||||
|
// Edge case: tool call appeared with result already (shouldn't happen normally,
|
||||||
|
// but handle it gracefully by emitting tool_use with 'executing' status first)
|
||||||
|
emitToolUseIfNeeded(toolCall, 'executing');
|
||||||
|
} else if (wasExecuting && isCompleted) {
|
||||||
|
// Status changed from executing to completed - ensure tool_use was emitted
|
||||||
|
emitToolUseIfNeeded(toolCall, 'executing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit tool_result if tool call is completed
|
||||||
|
if (isCompleted) {
|
||||||
|
emitToolResultIfNeeded(toolCall);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputUpdateHandler = (
|
||||||
|
callId: string,
|
||||||
|
outputChunk: ToolResultDisplay,
|
||||||
|
) => {
|
||||||
|
// Only process TaskResultDisplay (Task tool updates)
|
||||||
|
if (
|
||||||
|
typeof outputChunk === 'object' &&
|
||||||
|
outputChunk !== null &&
|
||||||
|
'type' in outputChunk &&
|
||||||
|
outputChunk.type === 'task_execution'
|
||||||
|
) {
|
||||||
|
const taskDisplay = outputChunk as TaskResultDisplay;
|
||||||
|
const previous = previousTaskStates.get(callId);
|
||||||
|
|
||||||
|
// If no adapter, just track state (for non-JSON modes)
|
||||||
|
if (!adapter) {
|
||||||
|
previousTaskStates.set(callId, taskDisplay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process if adapter supports subagent APIs
|
||||||
|
if (
|
||||||
|
!adapter.processSubagentToolCall ||
|
||||||
|
!adapter.emitSubagentErrorResult
|
||||||
|
) {
|
||||||
|
previousTaskStates.set(callId, taskDisplay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskDisplay.toolCalls) {
|
||||||
|
if (!previous || !previous.toolCalls) {
|
||||||
|
// First time seeing tool calls - process all initial ones
|
||||||
|
for (const toolCall of taskDisplay.toolCalls) {
|
||||||
|
processToolCall(toolCall);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Compare with previous state to find new/changed tool calls
|
||||||
|
for (const toolCall of taskDisplay.toolCalls) {
|
||||||
|
const previousCall = previous.toolCalls.find(
|
||||||
|
(tc) => tc.callId === toolCall.callId,
|
||||||
|
);
|
||||||
|
processToolCall(toolCall, previousCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle task-level errors (status: 'failed', 'cancelled')
|
||||||
|
if (
|
||||||
|
taskDisplay.status === 'failed' ||
|
||||||
|
taskDisplay.status === 'cancelled'
|
||||||
|
) {
|
||||||
|
const previousStatus = previous?.status;
|
||||||
|
// Only emit error result if status changed to failed/cancelled
|
||||||
|
if (
|
||||||
|
previousStatus !== 'failed' &&
|
||||||
|
previousStatus !== 'cancelled' &&
|
||||||
|
previousStatus !== undefined
|
||||||
|
) {
|
||||||
|
const errorMessage =
|
||||||
|
taskDisplay.terminateReason ||
|
||||||
|
(taskDisplay.status === 'cancelled'
|
||||||
|
? 'Task was cancelled'
|
||||||
|
: 'Task execution failed');
|
||||||
|
// Use subagent adapter's emitSubagentErrorResult method
|
||||||
|
adapter.emitSubagentErrorResult(errorMessage, 0, taskToolCallId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle subagent initial message (prompt) in non-interactive mode with json/stream-json output
|
||||||
|
// Emit when this is the first update (previous is undefined) and task starts
|
||||||
|
if (
|
||||||
|
!previous &&
|
||||||
|
taskDisplay.taskPrompt &&
|
||||||
|
!config.isInteractive() &&
|
||||||
|
(config.getOutputFormat() === OutputFormat.JSON ||
|
||||||
|
config.getOutputFormat() === OutputFormat.STREAM_JSON)
|
||||||
|
) {
|
||||||
|
// Emit the user message with the correct parent_tool_use_id
|
||||||
|
adapter.emitUserMessage(
|
||||||
|
[{ text: taskDisplay.taskPrompt }],
|
||||||
|
taskToolCallId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous state
|
||||||
|
previousTaskStates.set(callId, taskDisplay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No longer need to attach adapter to handler - task.ts uses TaskResultDisplay.message instead
|
||||||
|
|
||||||
|
return {
|
||||||
|
handler: outputUpdateHandler,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts function response parts to a string representation.
|
||||||
|
* Handles functionResponse parts specially by extracting their output content.
|
||||||
|
*
|
||||||
|
* @param parts - Array of Part objects to convert
|
||||||
|
* @returns String representation of the parts
|
||||||
|
*/
|
||||||
|
export function functionResponsePartsToString(parts: Part[]): string {
|
||||||
|
return parts
|
||||||
|
.map((part) => {
|
||||||
|
if ('functionResponse' in part) {
|
||||||
|
const content = part.functionResponse?.response?.['output'] ?? '';
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return JSON.stringify(part);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts content from a tool call response for inclusion in tool_result blocks.
|
||||||
|
* Uses functionResponsePartsToString to properly handle functionResponse parts,
|
||||||
|
* which correctly extracts output content from functionResponse objects rather
|
||||||
|
* than simply concatenating text or JSON.stringify.
|
||||||
|
*
|
||||||
|
* @param response - Tool call response information
|
||||||
|
* @returns String content for the tool_result block, or undefined if no content available
|
||||||
|
*/
|
||||||
|
export function toolResultContent(
|
||||||
|
response: ToolCallResponseInfo,
|
||||||
|
): string | undefined {
|
||||||
|
if (
|
||||||
|
typeof response.resultDisplay === 'string' &&
|
||||||
|
response.resultDisplay.trim().length > 0
|
||||||
|
) {
|
||||||
|
return response.resultDisplay;
|
||||||
|
}
|
||||||
|
if (response.responseParts && response.responseParts.length > 0) {
|
||||||
|
// Always use functionResponsePartsToString to properly handle
|
||||||
|
// functionResponse parts that contain output content
|
||||||
|
return functionResponsePartsToString(response.responseParts);
|
||||||
|
}
|
||||||
|
if (response.error) {
|
||||||
|
return response.error.message;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
|
|||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import * as auth from './config/auth.js';
|
import * as auth from './config/auth.js';
|
||||||
import { type LoadedSettings } from './config/settings.js';
|
import { type LoadedSettings } from './config/settings.js';
|
||||||
|
import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.js';
|
||||||
|
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
||||||
|
import * as cleanupModule from './utils/cleanup.js';
|
||||||
|
|
||||||
describe('validateNonInterActiveAuth', () => {
|
describe('validateNonInterActiveAuth', () => {
|
||||||
let originalEnvGeminiApiKey: string | undefined;
|
let originalEnvGeminiApiKey: string | undefined;
|
||||||
@@ -17,8 +20,8 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
let originalEnvGcp: string | undefined;
|
let originalEnvGcp: string | undefined;
|
||||||
let originalEnvOpenAiApiKey: string | undefined;
|
let originalEnvOpenAiApiKey: string | undefined;
|
||||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
let processExitSpy: ReturnType<typeof vi.spyOn>;
|
let processExitSpy: ReturnType<typeof vi.spyOn<[code?: number], never>>;
|
||||||
let refreshAuthMock: vi.Mock;
|
let refreshAuthMock: ReturnType<typeof vi.fn>;
|
||||||
let mockSettings: LoadedSettings;
|
let mockSettings: LoadedSettings;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -33,7 +36,7 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||||
throw new Error(`process.exit(${code}) called`);
|
throw new Error(`process.exit(${code}) called`);
|
||||||
});
|
}) as ReturnType<typeof vi.spyOn<[code?: number], never>>;
|
||||||
refreshAuthMock = vi.fn().mockResolvedValue('refreshed');
|
refreshAuthMock = vi.fn().mockResolvedValue('refreshed');
|
||||||
mockSettings = {
|
mockSettings = {
|
||||||
system: { path: '', settings: {} },
|
system: { path: '', settings: {} },
|
||||||
@@ -235,7 +238,24 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('JSON output mode', () => {
|
describe('JSON output mode', () => {
|
||||||
it('prints JSON error when no auth is configured and exits with code 1', async () => {
|
let emitResultMock: ReturnType<typeof vi.fn>;
|
||||||
|
let runExitCleanupMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
emitResultMock = vi.fn();
|
||||||
|
runExitCleanupMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(JsonOutputAdapterModule, 'JsonOutputAdapter').mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
emitResult: emitResultMock,
|
||||||
|
}) as unknown as JsonOutputAdapterModule.JsonOutputAdapter,
|
||||||
|
);
|
||||||
|
vi.spyOn(cleanupModule, 'runExitCleanup').mockImplementation(
|
||||||
|
runExitCleanupMock,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits error result and exits when no auth is configured', async () => {
|
||||||
const nonInteractiveConfig = {
|
const nonInteractiveConfig = {
|
||||||
refreshAuth: refreshAuthMock,
|
refreshAuth: refreshAuthMock,
|
||||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
||||||
@@ -244,7 +264,6 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
.mockReturnValue({ authType: undefined }),
|
.mockReturnValue({ authType: undefined }),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
let thrown: Error | undefined;
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -252,21 +271,27 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
);
|
);
|
||||||
|
expect.fail('Should have exited');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
thrown = e as Error;
|
expect((e as Error).message).toContain('process.exit(1) called');
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(thrown?.message).toBe('process.exit(1) called');
|
expect(emitResultMock).toHaveBeenCalledWith({
|
||||||
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
|
isError: true,
|
||||||
const payload = JSON.parse(errorArg);
|
errorMessage: expect.stringContaining(
|
||||||
expect(payload.error.type).toBe('Error');
|
'Please set an Auth method in your',
|
||||||
expect(payload.error.code).toBe(1);
|
),
|
||||||
expect(payload.error.message).toContain(
|
durationMs: 0,
|
||||||
'Please set an Auth method in your',
|
apiDurationMs: 0,
|
||||||
);
|
numTurns: 0,
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
expect(runExitCleanupMock).toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => {
|
it('emits error result and exits when enforced auth mismatches current auth', async () => {
|
||||||
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
|
|
||||||
@@ -278,7 +303,6 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
.mockReturnValue({ authType: undefined }),
|
.mockReturnValue({ authType: undefined }),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
let thrown: Error | undefined;
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -286,23 +310,27 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
);
|
);
|
||||||
|
expect.fail('Should have exited');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
thrown = e as Error;
|
expect((e as Error).message).toContain('process.exit(1) called');
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(thrown?.message).toBe('process.exit(1) called');
|
expect(emitResultMock).toHaveBeenCalledWith({
|
||||||
{
|
isError: true,
|
||||||
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
|
errorMessage: expect.stringContaining(
|
||||||
const payload = JSON.parse(errorArg);
|
|
||||||
expect(payload.error.type).toBe('Error');
|
|
||||||
expect(payload.error.code).toBe(1);
|
|
||||||
expect(payload.error.message).toContain(
|
|
||||||
'The configured auth type is qwen-oauth, but the current auth type is openai.',
|
'The configured auth type is qwen-oauth, but the current auth type is openai.',
|
||||||
);
|
),
|
||||||
}
|
durationMs: 0,
|
||||||
|
apiDurationMs: 0,
|
||||||
|
numTurns: 0,
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
expect(runExitCleanupMock).toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => {
|
it('emits error result and exits when validateAuthMethod fails', async () => {
|
||||||
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
||||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
|
|
||||||
@@ -314,7 +342,6 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
.mockReturnValue({ authType: undefined }),
|
.mockReturnValue({ authType: undefined }),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
let thrown: Error | undefined;
|
|
||||||
try {
|
try {
|
||||||
await validateNonInteractiveAuth(
|
await validateNonInteractiveAuth(
|
||||||
AuthType.USE_OPENAI,
|
AuthType.USE_OPENAI,
|
||||||
@@ -322,18 +349,159 @@ describe('validateNonInterActiveAuth', () => {
|
|||||||
nonInteractiveConfig,
|
nonInteractiveConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
);
|
);
|
||||||
|
expect.fail('Should have exited');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
thrown = e as Error;
|
expect((e as Error).message).toContain('process.exit(1) called');
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(thrown?.message).toBe('process.exit(1) called');
|
expect(emitResultMock).toHaveBeenCalledWith({
|
||||||
{
|
isError: true,
|
||||||
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
|
errorMessage: 'Auth error!',
|
||||||
const payload = JSON.parse(errorArg);
|
durationMs: 0,
|
||||||
expect(payload.error.type).toBe('Error');
|
apiDurationMs: 0,
|
||||||
expect(payload.error.code).toBe(1);
|
numTurns: 0,
|
||||||
expect(payload.error.message).toBe('Auth error!');
|
usage: undefined,
|
||||||
|
});
|
||||||
|
expect(runExitCleanupMock).toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('STREAM_JSON output mode', () => {
|
||||||
|
let emitResultMock: ReturnType<typeof vi.fn>;
|
||||||
|
let runExitCleanupMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
emitResultMock = vi.fn();
|
||||||
|
runExitCleanupMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(
|
||||||
|
StreamJsonOutputAdapterModule,
|
||||||
|
'StreamJsonOutputAdapter',
|
||||||
|
).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
emitResult: emitResultMock,
|
||||||
|
}) as unknown as StreamJsonOutputAdapterModule.StreamJsonOutputAdapter,
|
||||||
|
);
|
||||||
|
vi.spyOn(cleanupModule, 'runExitCleanup').mockImplementation(
|
||||||
|
runExitCleanupMock,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits error result and exits when no auth is configured', async () => {
|
||||||
|
const nonInteractiveConfig = {
|
||||||
|
refreshAuth: refreshAuthMock,
|
||||||
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
||||||
|
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
||||||
|
getContentGeneratorConfig: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ authType: undefined }),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await validateNonInteractiveAuth(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
nonInteractiveConfig,
|
||||||
|
mockSettings,
|
||||||
|
);
|
||||||
|
expect.fail('Should have exited');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toContain('process.exit(1) called');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expect(emitResultMock).toHaveBeenCalledWith({
|
||||||
|
isError: true,
|
||||||
|
errorMessage: expect.stringContaining(
|
||||||
|
'Please set an Auth method in your',
|
||||||
|
),
|
||||||
|
durationMs: 0,
|
||||||
|
apiDurationMs: 0,
|
||||||
|
numTurns: 0,
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
expect(runExitCleanupMock).toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits error result and exits when enforced auth mismatches current auth', async () => {
|
||||||
|
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
|
||||||
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
|
|
||||||
|
const nonInteractiveConfig = {
|
||||||
|
refreshAuth: refreshAuthMock,
|
||||||
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
||||||
|
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
||||||
|
getContentGeneratorConfig: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ authType: undefined }),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await validateNonInteractiveAuth(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
nonInteractiveConfig,
|
||||||
|
mockSettings,
|
||||||
|
);
|
||||||
|
expect.fail('Should have exited');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toContain('process.exit(1) called');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(emitResultMock).toHaveBeenCalledWith({
|
||||||
|
isError: true,
|
||||||
|
errorMessage: expect.stringContaining(
|
||||||
|
'The configured auth type is qwen-oauth, but the current auth type is openai.',
|
||||||
|
),
|
||||||
|
durationMs: 0,
|
||||||
|
apiDurationMs: 0,
|
||||||
|
numTurns: 0,
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
expect(runExitCleanupMock).toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits error result and exits when validateAuthMethod fails', async () => {
|
||||||
|
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
|
||||||
|
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||||
|
|
||||||
|
const nonInteractiveConfig = {
|
||||||
|
refreshAuth: refreshAuthMock,
|
||||||
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
|
||||||
|
getIncludePartialMessages: vi.fn().mockReturnValue(false),
|
||||||
|
getContentGeneratorConfig: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ authType: undefined }),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await validateNonInteractiveAuth(
|
||||||
|
AuthType.USE_OPENAI,
|
||||||
|
undefined,
|
||||||
|
nonInteractiveConfig,
|
||||||
|
mockSettings,
|
||||||
|
);
|
||||||
|
expect.fail('Should have exited');
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as Error).message).toContain('process.exit(1) called');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(emitResultMock).toHaveBeenCalledWith({
|
||||||
|
isError: true,
|
||||||
|
errorMessage: 'Auth error!',
|
||||||
|
durationMs: 0,
|
||||||
|
apiDurationMs: 0,
|
||||||
|
numTurns: 0,
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
expect(runExitCleanupMock).toHaveBeenCalled();
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
|
|||||||
import { USER_SETTINGS_PATH } from './config/settings.js';
|
import { USER_SETTINGS_PATH } from './config/settings.js';
|
||||||
import { validateAuthMethod } from './config/auth.js';
|
import { validateAuthMethod } from './config/auth.js';
|
||||||
import { type LoadedSettings } from './config/settings.js';
|
import { type LoadedSettings } from './config/settings.js';
|
||||||
import { handleError } from './utils/errors.js';
|
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
|
||||||
|
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
||||||
|
import { runExitCleanup } from './utils/cleanup.js';
|
||||||
|
|
||||||
function getAuthTypeFromEnv(): AuthType | undefined {
|
function getAuthTypeFromEnv(): AuthType | undefined {
|
||||||
if (process.env['OPENAI_API_KEY']) {
|
if (process.env['OPENAI_API_KEY']) {
|
||||||
@@ -27,7 +29,7 @@ export async function validateNonInteractiveAuth(
|
|||||||
useExternalAuth: boolean | undefined,
|
useExternalAuth: boolean | undefined,
|
||||||
nonInteractiveConfig: Config,
|
nonInteractiveConfig: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
) {
|
): Promise<Config> {
|
||||||
try {
|
try {
|
||||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||||
if (enforcedType) {
|
if (enforcedType) {
|
||||||
@@ -58,15 +60,38 @@ export async function validateNonInteractiveAuth(
|
|||||||
await nonInteractiveConfig.refreshAuth(authType);
|
await nonInteractiveConfig.refreshAuth(authType);
|
||||||
return nonInteractiveConfig;
|
return nonInteractiveConfig;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (nonInteractiveConfig.getOutputFormat() === OutputFormat.JSON) {
|
const outputFormat = nonInteractiveConfig.getOutputFormat();
|
||||||
handleError(
|
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
// In JSON and STREAM_JSON modes, emit error result and exit
|
||||||
nonInteractiveConfig,
|
if (
|
||||||
1,
|
outputFormat === OutputFormat.JSON ||
|
||||||
);
|
outputFormat === OutputFormat.STREAM_JSON
|
||||||
} else {
|
) {
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
let adapter;
|
||||||
|
if (outputFormat === OutputFormat.JSON) {
|
||||||
|
adapter = new JsonOutputAdapter(nonInteractiveConfig);
|
||||||
|
} else {
|
||||||
|
adapter = new StreamJsonOutputAdapter(
|
||||||
|
nonInteractiveConfig,
|
||||||
|
nonInteractiveConfig.getIncludePartialMessages(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
adapter.emitResult({
|
||||||
|
isError: true,
|
||||||
|
errorMessage,
|
||||||
|
durationMs: 0,
|
||||||
|
apiDurationMs: 0,
|
||||||
|
numTurns: 0,
|
||||||
|
usage: undefined,
|
||||||
|
});
|
||||||
|
await runExitCleanup();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For other modes (text), use existing error handling
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import { WriteFileTool } from '../tools/write-file.js';
|
|||||||
|
|
||||||
// Other modules
|
// Other modules
|
||||||
import { ideContextStore } from '../ide/ideContext.js';
|
import { ideContextStore } from '../ide/ideContext.js';
|
||||||
import { OutputFormat } from '../output/types.js';
|
import { InputFormat, OutputFormat } from '../output/types.js';
|
||||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||||
import { SubagentManager } from '../subagents/subagent-manager.js';
|
import { SubagentManager } from '../subagents/subagent-manager.js';
|
||||||
import {
|
import {
|
||||||
@@ -216,6 +216,7 @@ export interface ConfigParameters {
|
|||||||
sandbox?: SandboxConfig;
|
sandbox?: SandboxConfig;
|
||||||
targetDir: string;
|
targetDir: string;
|
||||||
debugMode: boolean;
|
debugMode: boolean;
|
||||||
|
includePartialMessages?: boolean;
|
||||||
question?: string;
|
question?: string;
|
||||||
fullContext?: boolean;
|
fullContext?: boolean;
|
||||||
coreTools?: string[];
|
coreTools?: string[];
|
||||||
@@ -290,6 +291,27 @@ export interface ConfigParameters {
|
|||||||
useSmartEdit?: boolean;
|
useSmartEdit?: boolean;
|
||||||
output?: OutputSettings;
|
output?: OutputSettings;
|
||||||
skipStartupContext?: boolean;
|
skipStartupContext?: boolean;
|
||||||
|
inputFormat?: InputFormat;
|
||||||
|
outputFormat?: OutputFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigOutputFormat(
|
||||||
|
format: OutputFormat | undefined,
|
||||||
|
): OutputFormat | undefined {
|
||||||
|
if (!format) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
switch (format) {
|
||||||
|
case 'stream-json':
|
||||||
|
return OutputFormat.STREAM_JSON;
|
||||||
|
case 'json':
|
||||||
|
case OutputFormat.JSON:
|
||||||
|
return OutputFormat.JSON;
|
||||||
|
case 'text':
|
||||||
|
case OutputFormat.TEXT:
|
||||||
|
default:
|
||||||
|
return OutputFormat.TEXT;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
@@ -306,6 +328,9 @@ export class Config {
|
|||||||
private readonly targetDir: string;
|
private readonly targetDir: string;
|
||||||
private workspaceContext: WorkspaceContext;
|
private workspaceContext: WorkspaceContext;
|
||||||
private readonly debugMode: boolean;
|
private readonly debugMode: boolean;
|
||||||
|
private readonly inputFormat: InputFormat;
|
||||||
|
private readonly outputFormat: OutputFormat;
|
||||||
|
private readonly includePartialMessages: boolean;
|
||||||
private readonly question: string | undefined;
|
private readonly question: string | undefined;
|
||||||
private readonly fullContext: boolean;
|
private readonly fullContext: boolean;
|
||||||
private readonly coreTools: string[] | undefined;
|
private readonly coreTools: string[] | undefined;
|
||||||
@@ -388,7 +413,6 @@ export class Config {
|
|||||||
private readonly enableToolOutputTruncation: boolean;
|
private readonly enableToolOutputTruncation: boolean;
|
||||||
private readonly eventEmitter?: EventEmitter;
|
private readonly eventEmitter?: EventEmitter;
|
||||||
private readonly useSmartEdit: boolean;
|
private readonly useSmartEdit: boolean;
|
||||||
private readonly outputSettings: OutputSettings;
|
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
this.sessionId = params.sessionId;
|
this.sessionId = params.sessionId;
|
||||||
@@ -401,6 +425,12 @@ export class Config {
|
|||||||
params.includeDirectories ?? [],
|
params.includeDirectories ?? [],
|
||||||
);
|
);
|
||||||
this.debugMode = params.debugMode;
|
this.debugMode = params.debugMode;
|
||||||
|
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
|
||||||
|
const normalizedOutputFormat = normalizeConfigOutputFormat(
|
||||||
|
params.outputFormat ?? params.output?.format,
|
||||||
|
);
|
||||||
|
this.outputFormat = normalizedOutputFormat ?? OutputFormat.TEXT;
|
||||||
|
this.includePartialMessages = params.includePartialMessages ?? false;
|
||||||
this.question = params.question;
|
this.question = params.question;
|
||||||
this.fullContext = params.fullContext ?? false;
|
this.fullContext = params.fullContext ?? false;
|
||||||
this.coreTools = params.coreTools;
|
this.coreTools = params.coreTools;
|
||||||
@@ -495,12 +525,9 @@ export class Config {
|
|||||||
this.extensionManagement = params.extensionManagement ?? true;
|
this.extensionManagement = params.extensionManagement ?? true;
|
||||||
this.storage = new Storage(this.targetDir);
|
this.storage = new Storage(this.targetDir);
|
||||||
this.vlmSwitchMode = params.vlmSwitchMode;
|
this.vlmSwitchMode = params.vlmSwitchMode;
|
||||||
|
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
|
||||||
this.fileExclusions = new FileExclusions(this);
|
this.fileExclusions = new FileExclusions(this);
|
||||||
this.eventEmitter = params.eventEmitter;
|
this.eventEmitter = params.eventEmitter;
|
||||||
this.outputSettings = {
|
|
||||||
format: params.output?.format ?? OutputFormat.TEXT,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (params.contextFileName) {
|
if (params.contextFileName) {
|
||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
}
|
}
|
||||||
@@ -786,6 +813,14 @@ export class Config {
|
|||||||
return this.showMemoryUsage;
|
return this.showMemoryUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInputFormat(): 'text' | 'stream-json' {
|
||||||
|
return this.inputFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIncludePartialMessages(): boolean {
|
||||||
|
return this.includePartialMessages;
|
||||||
|
}
|
||||||
|
|
||||||
getAccessibility(): AccessibilitySettings {
|
getAccessibility(): AccessibilitySettings {
|
||||||
return this.accessibility;
|
return this.accessibility;
|
||||||
}
|
}
|
||||||
@@ -1082,9 +1117,7 @@ export class Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getOutputFormat(): OutputFormat {
|
getOutputFormat(): OutputFormat {
|
||||||
return this.outputSettings?.format
|
return this.outputFormat;
|
||||||
? this.outputSettings.format
|
|
||||||
: OutputFormat.TEXT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGitService(): Promise<GitService> {
|
async getGitService(): Promise<GitService> {
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ describe('CoreToolScheduler', () => {
|
|||||||
getUseSmartEdit: () => false,
|
getUseSmartEdit: () => false,
|
||||||
getUseModelRouter: () => false,
|
getUseModelRouter: () => false,
|
||||||
getGeminiClient: () => null, // No client needed for these tests
|
getGeminiClient: () => null, // No client needed for these tests
|
||||||
|
getExcludeTools: () => undefined,
|
||||||
|
isInteractive: () => true,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
const mockToolRegistry = {
|
const mockToolRegistry = {
|
||||||
getAllToolNames: () => ['list_files', 'read_file', 'write_file'],
|
getAllToolNames: () => ['list_files', 'read_file', 'write_file'],
|
||||||
@@ -400,6 +402,241 @@ describe('CoreToolScheduler', () => {
|
|||||||
' Did you mean one of: "list_files", "read_file", "write_file"?',
|
' Did you mean one of: "list_files", "read_file", "write_file"?',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use Levenshtein suggestions for excluded tools (getToolSuggestion only handles non-excluded)', () => {
|
||||||
|
// Create mocked tool registry
|
||||||
|
const mockToolRegistry = {
|
||||||
|
getAllToolNames: () => ['list_files', 'read_file'],
|
||||||
|
} as unknown as ToolRegistry;
|
||||||
|
|
||||||
|
// Create mocked config with excluded tools
|
||||||
|
const mockConfig = {
|
||||||
|
getToolRegistry: () => mockToolRegistry,
|
||||||
|
getUseSmartEdit: () => false,
|
||||||
|
getUseModelRouter: () => false,
|
||||||
|
getGeminiClient: () => null,
|
||||||
|
getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'],
|
||||||
|
isInteractive: () => false, // Value doesn't matter, but included for completeness
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
// Create scheduler
|
||||||
|
const scheduler = new CoreToolScheduler({
|
||||||
|
config: mockConfig,
|
||||||
|
getPreferredEditor: () => 'vscode',
|
||||||
|
onEditorClose: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// getToolSuggestion no longer handles excluded tools - it only handles truly missing tools
|
||||||
|
// So excluded tools will use Levenshtein distance to find similar registered tools
|
||||||
|
// @ts-expect-error accessing private method
|
||||||
|
const excludedTool = scheduler.getToolSuggestion('write_file');
|
||||||
|
expect(excludedTool).toContain('Did you mean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use Levenshtein suggestions for non-excluded tools', () => {
|
||||||
|
// Create mocked tool registry
|
||||||
|
const mockToolRegistry = {
|
||||||
|
getAllToolNames: () => ['list_files', 'read_file'],
|
||||||
|
} as unknown as ToolRegistry;
|
||||||
|
|
||||||
|
// Create mocked config with excluded tools
|
||||||
|
const mockConfig = {
|
||||||
|
getToolRegistry: () => mockToolRegistry,
|
||||||
|
getUseSmartEdit: () => false,
|
||||||
|
getUseModelRouter: () => false,
|
||||||
|
getGeminiClient: () => null,
|
||||||
|
getExcludeTools: () => ['write_file', 'edit'],
|
||||||
|
isInteractive: () => false, // Value doesn't matter
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
// Create scheduler
|
||||||
|
const scheduler = new CoreToolScheduler({
|
||||||
|
config: mockConfig,
|
||||||
|
getPreferredEditor: () => 'vscode',
|
||||||
|
onEditorClose: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that non-excluded tool (hallucinated) still uses Levenshtein suggestions
|
||||||
|
// @ts-expect-error accessing private method
|
||||||
|
const hallucinatedTool = scheduler.getToolSuggestion('list_fils');
|
||||||
|
expect(hallucinatedTool).toContain('Did you mean');
|
||||||
|
expect(hallucinatedTool).not.toContain(
|
||||||
|
'not available in the current environment',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('excluded tools handling', () => {
|
||||||
|
it('should return permission error for excluded tools instead of "not found" message', async () => {
|
||||||
|
const onAllToolCallsComplete = vi.fn();
|
||||||
|
const onToolCallsUpdate = vi.fn();
|
||||||
|
|
||||||
|
const mockToolRegistry = {
|
||||||
|
getTool: () => undefined, // Tool not in registry
|
||||||
|
getAllToolNames: () => ['list_files', 'read_file'],
|
||||||
|
getFunctionDeclarations: () => [],
|
||||||
|
tools: new Map(),
|
||||||
|
discovery: {},
|
||||||
|
registerTool: () => {},
|
||||||
|
getToolByName: () => undefined,
|
||||||
|
getToolByDisplayName: () => undefined,
|
||||||
|
getTools: () => [],
|
||||||
|
discoverTools: async () => {},
|
||||||
|
getAllTools: () => [],
|
||||||
|
getToolsByServer: () => [],
|
||||||
|
} as unknown as ToolRegistry;
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
getSessionId: () => 'test-session-id',
|
||||||
|
getUsageStatisticsEnabled: () => true,
|
||||||
|
getDebugMode: () => false,
|
||||||
|
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||||
|
getAllowedTools: () => [],
|
||||||
|
getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'],
|
||||||
|
getContentGeneratorConfig: () => ({
|
||||||
|
model: 'test-model',
|
||||||
|
authType: 'oauth-personal',
|
||||||
|
}),
|
||||||
|
getShellExecutionConfig: () => ({
|
||||||
|
terminalWidth: 90,
|
||||||
|
terminalHeight: 30,
|
||||||
|
}),
|
||||||
|
storage: {
|
||||||
|
getProjectTempDir: () => '/tmp',
|
||||||
|
},
|
||||||
|
getTruncateToolOutputThreshold: () =>
|
||||||
|
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||||
|
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||||
|
getToolRegistry: () => mockToolRegistry,
|
||||||
|
getUseSmartEdit: () => false,
|
||||||
|
getUseModelRouter: () => false,
|
||||||
|
getGeminiClient: () => null,
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const scheduler = new CoreToolScheduler({
|
||||||
|
config: mockConfig,
|
||||||
|
onAllToolCallsComplete,
|
||||||
|
onToolCallsUpdate,
|
||||||
|
getPreferredEditor: () => 'vscode',
|
||||||
|
onEditorClose: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const request = {
|
||||||
|
callId: '1',
|
||||||
|
name: 'write_file', // Excluded tool
|
||||||
|
args: {},
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-id-excluded',
|
||||||
|
};
|
||||||
|
|
||||||
|
await scheduler.schedule([request], abortController.signal);
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onAllToolCallsComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedCalls = onAllToolCallsComplete.mock
|
||||||
|
.calls[0][0] as ToolCall[];
|
||||||
|
expect(completedCalls).toHaveLength(1);
|
||||||
|
const completedCall = completedCalls[0];
|
||||||
|
expect(completedCall.status).toBe('error');
|
||||||
|
|
||||||
|
if (completedCall.status === 'error') {
|
||||||
|
const errorMessage = completedCall.response.error?.message;
|
||||||
|
expect(errorMessage).toBe(
|
||||||
|
'Qwen Code requires permission to use write_file, but that permission was declined.',
|
||||||
|
);
|
||||||
|
// Should NOT contain "not found in registry"
|
||||||
|
expect(errorMessage).not.toContain('not found in registry');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "not found" message for truly missing tools (not excluded)', async () => {
|
||||||
|
const onAllToolCallsComplete = vi.fn();
|
||||||
|
const onToolCallsUpdate = vi.fn();
|
||||||
|
|
||||||
|
const mockToolRegistry = {
|
||||||
|
getTool: () => undefined, // Tool not in registry
|
||||||
|
getAllToolNames: () => ['list_files', 'read_file'],
|
||||||
|
getFunctionDeclarations: () => [],
|
||||||
|
tools: new Map(),
|
||||||
|
discovery: {},
|
||||||
|
registerTool: () => {},
|
||||||
|
getToolByName: () => undefined,
|
||||||
|
getToolByDisplayName: () => undefined,
|
||||||
|
getTools: () => [],
|
||||||
|
discoverTools: async () => {},
|
||||||
|
getAllTools: () => [],
|
||||||
|
getToolsByServer: () => [],
|
||||||
|
} as unknown as ToolRegistry;
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
getSessionId: () => 'test-session-id',
|
||||||
|
getUsageStatisticsEnabled: () => true,
|
||||||
|
getDebugMode: () => false,
|
||||||
|
getApprovalMode: () => ApprovalMode.DEFAULT,
|
||||||
|
getAllowedTools: () => [],
|
||||||
|
getExcludeTools: () => ['write_file', 'edit'], // Different excluded tools
|
||||||
|
getContentGeneratorConfig: () => ({
|
||||||
|
model: 'test-model',
|
||||||
|
authType: 'oauth-personal',
|
||||||
|
}),
|
||||||
|
getShellExecutionConfig: () => ({
|
||||||
|
terminalWidth: 90,
|
||||||
|
terminalHeight: 30,
|
||||||
|
}),
|
||||||
|
storage: {
|
||||||
|
getProjectTempDir: () => '/tmp',
|
||||||
|
},
|
||||||
|
getTruncateToolOutputThreshold: () =>
|
||||||
|
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||||
|
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||||
|
getToolRegistry: () => mockToolRegistry,
|
||||||
|
getUseSmartEdit: () => false,
|
||||||
|
getUseModelRouter: () => false,
|
||||||
|
getGeminiClient: () => null,
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
const scheduler = new CoreToolScheduler({
|
||||||
|
config: mockConfig,
|
||||||
|
onAllToolCallsComplete,
|
||||||
|
onToolCallsUpdate,
|
||||||
|
getPreferredEditor: () => 'vscode',
|
||||||
|
onEditorClose: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const request = {
|
||||||
|
callId: '1',
|
||||||
|
name: 'nonexistent_tool', // Not excluded, just doesn't exist
|
||||||
|
args: {},
|
||||||
|
isClientInitiated: false,
|
||||||
|
prompt_id: 'prompt-id-missing',
|
||||||
|
};
|
||||||
|
|
||||||
|
await scheduler.schedule([request], abortController.signal);
|
||||||
|
|
||||||
|
// Wait for completion
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(onAllToolCallsComplete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedCalls = onAllToolCallsComplete.mock
|
||||||
|
.calls[0][0] as ToolCall[];
|
||||||
|
expect(completedCalls).toHaveLength(1);
|
||||||
|
const completedCall = completedCalls[0];
|
||||||
|
expect(completedCall.status).toBe('error');
|
||||||
|
|
||||||
|
if (completedCall.status === 'error') {
|
||||||
|
const errorMessage = completedCall.response.error?.message;
|
||||||
|
// Should contain "not found in registry"
|
||||||
|
expect(errorMessage).toContain('not found in registry');
|
||||||
|
// Should NOT contain permission message
|
||||||
|
expect(errorMessage).not.toContain('requires permission');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -449,6 +686,9 @@ describe('CoreToolScheduler with payload', () => {
|
|||||||
getUseSmartEdit: () => false,
|
getUseSmartEdit: () => false,
|
||||||
getUseModelRouter: () => false,
|
getUseModelRouter: () => false,
|
||||||
getGeminiClient: () => null, // No client needed for these tests
|
getGeminiClient: () => null, // No client needed for these tests
|
||||||
|
isInteractive: () => true, // Required to prevent auto-denial of tool calls
|
||||||
|
getIdeMode: () => false,
|
||||||
|
getExperimentalZedIntegration: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const scheduler = new CoreToolScheduler({
|
const scheduler = new CoreToolScheduler({
|
||||||
@@ -769,6 +1009,9 @@ describe('CoreToolScheduler edit cancellation', () => {
|
|||||||
getUseSmartEdit: () => false,
|
getUseSmartEdit: () => false,
|
||||||
getUseModelRouter: () => false,
|
getUseModelRouter: () => false,
|
||||||
getGeminiClient: () => null, // No client needed for these tests
|
getGeminiClient: () => null, // No client needed for these tests
|
||||||
|
isInteractive: () => true, // Required to prevent auto-denial of tool calls
|
||||||
|
getIdeMode: () => false,
|
||||||
|
getExperimentalZedIntegration: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const scheduler = new CoreToolScheduler({
|
const scheduler = new CoreToolScheduler({
|
||||||
@@ -1421,6 +1664,9 @@ describe('CoreToolScheduler request queueing', () => {
|
|||||||
getUseSmartEdit: () => false,
|
getUseSmartEdit: () => false,
|
||||||
getUseModelRouter: () => false,
|
getUseModelRouter: () => false,
|
||||||
getGeminiClient: () => null, // No client needed for these tests
|
getGeminiClient: () => null, // No client needed for these tests
|
||||||
|
isInteractive: () => true, // Required to prevent auto-denial of tool calls
|
||||||
|
getIdeMode: () => false,
|
||||||
|
getExperimentalZedIntegration: () => false,
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const testTool = new TestApprovalTool(mockConfig);
|
const testTool = new TestApprovalTool(mockConfig);
|
||||||
@@ -1450,7 +1696,10 @@ describe('CoreToolScheduler request queueing', () => {
|
|||||||
const onAllToolCallsComplete = vi.fn();
|
const onAllToolCallsComplete = vi.fn();
|
||||||
const onToolCallsUpdate = vi.fn();
|
const onToolCallsUpdate = vi.fn();
|
||||||
const pendingConfirmations: Array<
|
const pendingConfirmations: Array<
|
||||||
(outcome: ToolConfirmationOutcome) => void
|
(
|
||||||
|
outcome: ToolConfirmationOutcome,
|
||||||
|
payload?: ToolConfirmationPayload,
|
||||||
|
) => Promise<void>
|
||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
const scheduler = new CoreToolScheduler({
|
const scheduler = new CoreToolScheduler({
|
||||||
@@ -1521,7 +1770,7 @@ describe('CoreToolScheduler request queueing', () => {
|
|||||||
|
|
||||||
// Approve the first tool with ProceedAlways
|
// Approve the first tool with ProceedAlways
|
||||||
const firstConfirmation = pendingConfirmations[0];
|
const firstConfirmation = pendingConfirmations[0];
|
||||||
firstConfirmation(ToolConfirmationOutcome.ProceedAlways);
|
await firstConfirmation(ToolConfirmationOutcome.ProceedAlways);
|
||||||
|
|
||||||
// Wait for all tools to be completed
|
// Wait for all tools to be completed
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
|
|||||||
@@ -587,12 +587,16 @@ export class CoreToolScheduler {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a suggestion string for a tool name that was not found in the registry.
|
* Generates a suggestion string for a tool name that was not found in the registry.
|
||||||
* It finds the closest matches based on Levenshtein distance.
|
* Uses Levenshtein distance to suggest similar tool names for hallucinated or misspelled tools.
|
||||||
|
* Note: Excluded tools are handled separately before calling this method, so this only
|
||||||
|
* handles the case where a tool is truly not found (hallucinated or typo).
|
||||||
* @param unknownToolName The tool name that was not found.
|
* @param unknownToolName The tool name that was not found.
|
||||||
* @param topN The number of suggestions to return. Defaults to 3.
|
* @param topN The number of suggestions to return. Defaults to 3.
|
||||||
* @returns A suggestion string like " Did you mean 'tool'?" or " Did you mean one of: 'tool1', 'tool2'?", or an empty string if no suggestions are found.
|
* @returns A suggestion string like " Did you mean 'tool'?" or " Did you mean one of: 'tool1', 'tool2'?",
|
||||||
|
* or an empty string if no suggestions are found.
|
||||||
*/
|
*/
|
||||||
private getToolSuggestion(unknownToolName: string, topN = 3): string {
|
private getToolSuggestion(unknownToolName: string, topN = 3): string {
|
||||||
|
// Use Levenshtein distance to find similar tool names from the registry.
|
||||||
const allToolNames = this.toolRegistry.getAllToolNames();
|
const allToolNames = this.toolRegistry.getAllToolNames();
|
||||||
|
|
||||||
const matches = allToolNames.map((toolName) => ({
|
const matches = allToolNames.map((toolName) => ({
|
||||||
@@ -670,8 +674,35 @@ export class CoreToolScheduler {
|
|||||||
|
|
||||||
const newToolCalls: ToolCall[] = requestsToProcess.map(
|
const newToolCalls: ToolCall[] = requestsToProcess.map(
|
||||||
(reqInfo): ToolCall => {
|
(reqInfo): ToolCall => {
|
||||||
|
// Check if the tool is excluded due to permissions/environment restrictions
|
||||||
|
// This check should happen before registry lookup to provide a clear permission error
|
||||||
|
const excludeTools = this.config.getExcludeTools?.() ?? undefined;
|
||||||
|
if (excludeTools && excludeTools.length > 0) {
|
||||||
|
const normalizedToolName = reqInfo.name.toLowerCase().trim();
|
||||||
|
const excludedMatch = excludeTools.find(
|
||||||
|
(excludedTool) =>
|
||||||
|
excludedTool.toLowerCase().trim() === normalizedToolName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (excludedMatch) {
|
||||||
|
// The tool exists but is excluded - return permission error directly
|
||||||
|
const permissionErrorMessage = `Qwen Code requires permission to use ${excludedMatch}, but that permission was declined.`;
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
request: reqInfo,
|
||||||
|
response: createErrorResponse(
|
||||||
|
reqInfo,
|
||||||
|
new Error(permissionErrorMessage),
|
||||||
|
ToolErrorType.EXECUTION_DENIED,
|
||||||
|
),
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toolInstance = this.toolRegistry.getTool(reqInfo.name);
|
const toolInstance = this.toolRegistry.getTool(reqInfo.name);
|
||||||
if (!toolInstance) {
|
if (!toolInstance) {
|
||||||
|
// Tool is not in registry and not excluded - likely hallucinated or typo
|
||||||
const suggestion = this.getToolSuggestion(reqInfo.name);
|
const suggestion = this.getToolSuggestion(reqInfo.name);
|
||||||
const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`;
|
const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`;
|
||||||
return {
|
return {
|
||||||
@@ -777,6 +808,32 @@ export class CoreToolScheduler {
|
|||||||
);
|
);
|
||||||
this.setStatusInternal(reqInfo.callId, 'scheduled');
|
this.setStatusInternal(reqInfo.callId, 'scheduled');
|
||||||
} else {
|
} else {
|
||||||
|
/**
|
||||||
|
* In non-interactive mode where no user will respond to approval prompts,
|
||||||
|
* and not running as IDE companion or Zed integration, automatically deny approval.
|
||||||
|
* This is intended to create an explicit denial of the tool call,
|
||||||
|
* rather than silently waiting for approval and hanging forever.
|
||||||
|
*/
|
||||||
|
const shouldAutoDeny =
|
||||||
|
!this.config.isInteractive() &&
|
||||||
|
!this.config.getIdeMode() &&
|
||||||
|
!this.config.getExperimentalZedIntegration();
|
||||||
|
|
||||||
|
if (shouldAutoDeny) {
|
||||||
|
// Treat as execution denied error, similar to excluded tools
|
||||||
|
const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`;
|
||||||
|
this.setStatusInternal(
|
||||||
|
reqInfo.callId,
|
||||||
|
'error',
|
||||||
|
createErrorResponse(
|
||||||
|
reqInfo,
|
||||||
|
new Error(errorMessage),
|
||||||
|
ToolErrorType.EXECUTION_DENIED,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Allow IDE to resolve confirmation
|
// Allow IDE to resolve confirmation
|
||||||
if (
|
if (
|
||||||
confirmationDetails.type === 'edit' &&
|
confirmationDetails.type === 'edit' &&
|
||||||
|
|||||||
@@ -9,7 +9,18 @@ import type {
|
|||||||
ToolCallResponseInfo,
|
ToolCallResponseInfo,
|
||||||
Config,
|
Config,
|
||||||
} from '../index.js';
|
} from '../index.js';
|
||||||
import { CoreToolScheduler } from './coreToolScheduler.js';
|
import {
|
||||||
|
CoreToolScheduler,
|
||||||
|
type AllToolCallsCompleteHandler,
|
||||||
|
type OutputUpdateHandler,
|
||||||
|
type ToolCallsUpdateHandler,
|
||||||
|
} from './coreToolScheduler.js';
|
||||||
|
|
||||||
|
export interface ExecuteToolCallOptions {
|
||||||
|
outputUpdateHandler?: OutputUpdateHandler;
|
||||||
|
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
|
||||||
|
onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a single tool call non-interactively by leveraging the CoreToolScheduler.
|
* Executes a single tool call non-interactively by leveraging the CoreToolScheduler.
|
||||||
@@ -18,15 +29,21 @@ export async function executeToolCall(
|
|||||||
config: Config,
|
config: Config,
|
||||||
toolCallRequest: ToolCallRequestInfo,
|
toolCallRequest: ToolCallRequestInfo,
|
||||||
abortSignal: AbortSignal,
|
abortSignal: AbortSignal,
|
||||||
|
options: ExecuteToolCallOptions = {},
|
||||||
): Promise<ToolCallResponseInfo> {
|
): Promise<ToolCallResponseInfo> {
|
||||||
return new Promise<ToolCallResponseInfo>((resolve, reject) => {
|
return new Promise<ToolCallResponseInfo>((resolve, reject) => {
|
||||||
new CoreToolScheduler({
|
new CoreToolScheduler({
|
||||||
config,
|
config,
|
||||||
getPreferredEditor: () => undefined,
|
outputUpdateHandler: options.outputUpdateHandler,
|
||||||
onEditorClose: () => {},
|
|
||||||
onAllToolCallsComplete: async (completedToolCalls) => {
|
onAllToolCallsComplete: async (completedToolCalls) => {
|
||||||
|
if (options.onAllToolCallsComplete) {
|
||||||
|
await options.onAllToolCallsComplete(completedToolCalls);
|
||||||
|
}
|
||||||
resolve(completedToolCalls[0].response);
|
resolve(completedToolCalls[0].response);
|
||||||
},
|
},
|
||||||
|
onToolCallsUpdate: options.onToolCallsUpdate,
|
||||||
|
getPreferredEditor: () => undefined,
|
||||||
|
onEditorClose: () => {},
|
||||||
})
|
})
|
||||||
.schedule(toolCallRequest, abortSignal)
|
.schedule(toolCallRequest, abortSignal)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
|
|||||||
@@ -6,9 +6,15 @@
|
|||||||
|
|
||||||
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
|
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
|
||||||
|
|
||||||
|
export enum InputFormat {
|
||||||
|
TEXT = 'text',
|
||||||
|
STREAM_JSON = 'stream-json',
|
||||||
|
}
|
||||||
|
|
||||||
export enum OutputFormat {
|
export enum OutputFormat {
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
JSON = 'json',
|
JSON = 'json',
|
||||||
|
STREAM_JSON = 'stream-json',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JsonError {
|
export interface JsonError {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
} from '../tools/tools.js';
|
} from '../tools/tools.js';
|
||||||
|
import type { Part } from '@google/genai';
|
||||||
|
|
||||||
export type SubAgentEvent =
|
export type SubAgentEvent =
|
||||||
| 'start'
|
| 'start'
|
||||||
@@ -72,6 +73,7 @@ export interface SubAgentToolResultEvent {
|
|||||||
name: string;
|
name: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
responseParts?: Part[];
|
||||||
resultDisplay?: string;
|
resultDisplay?: string;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|||||||
@@ -619,6 +619,13 @@ export class SubAgentScope {
|
|||||||
name: toolName,
|
name: toolName,
|
||||||
success,
|
success,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
|
responseParts: call.response.responseParts,
|
||||||
|
/**
|
||||||
|
* Tools like todoWrite will add some extra contents to the result,
|
||||||
|
* making it unable to deserialize the `responseParts` to a JSON object.
|
||||||
|
* While `resultDisplay` is normally a string, if not we stringify it,
|
||||||
|
* so that we can deserialize it to a JSON object when needed.
|
||||||
|
*/
|
||||||
resultDisplay: call.response.resultDisplay
|
resultDisplay: call.response.resultDisplay
|
||||||
? typeof call.response.resultDisplay === 'string'
|
? typeof call.response.resultDisplay === 'string'
|
||||||
? call.response.resultDisplay
|
? call.response.resultDisplay
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
|
|||||||
...this.currentToolCalls![toolCallIndex],
|
...this.currentToolCalls![toolCallIndex],
|
||||||
status: event.success ? 'success' : 'failed',
|
status: event.success ? 'success' : 'failed',
|
||||||
error: event.error,
|
error: event.error,
|
||||||
resultDisplay: event.resultDisplay,
|
responseParts: event.responseParts,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.updateDisplay(
|
this.updateDisplay(
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export enum ToolErrorType {
|
|||||||
UNHANDLED_EXCEPTION = 'unhandled_exception',
|
UNHANDLED_EXCEPTION = 'unhandled_exception',
|
||||||
TOOL_NOT_REGISTERED = 'tool_not_registered',
|
TOOL_NOT_REGISTERED = 'tool_not_registered',
|
||||||
EXECUTION_FAILED = 'execution_failed',
|
EXECUTION_FAILED = 'execution_failed',
|
||||||
|
// Try to execute a tool that is excluded due to the approval mode
|
||||||
|
EXECUTION_DENIED = 'execution_denied',
|
||||||
|
|
||||||
// File System Errors
|
// File System Errors
|
||||||
FILE_NOT_FOUND = 'file_not_found',
|
FILE_NOT_FOUND = 'file_not_found',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FunctionDeclaration, PartListUnion } from '@google/genai';
|
import type { FunctionDeclaration, Part, PartListUnion } from '@google/genai';
|
||||||
import { ToolErrorType } from './tool-error.js';
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import type { DiffUpdateResult } from '../ide/ide-client.js';
|
import type { DiffUpdateResult } from '../ide/ide-client.js';
|
||||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||||
@@ -461,6 +461,7 @@ export interface TaskResultDisplay {
|
|||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
result?: string;
|
result?: string;
|
||||||
resultDisplay?: string;
|
resultDisplay?: string;
|
||||||
|
responseParts?: Part[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user