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",
|
||||
"name": "Launch CLI Non-Interactive",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"],
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"start",
|
||||
"--",
|
||||
"-p",
|
||||
"${input:prompt}",
|
||||
"-y",
|
||||
"--output-format",
|
||||
"stream-json"
|
||||
],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
|
||||
@@ -548,12 +548,25 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- The prompt is processed within the interactive session, not before it.
|
||||
- Cannot be used when piping input from stdin.
|
||||
- Example: `qwen -i "explain this code"`
|
||||
- **`--output-format <format>`**:
|
||||
- **`--output-format <format>`** (**`-o <format>`**):
|
||||
- **Description:** Specifies the format of the CLI output for non-interactive mode.
|
||||
- **Values:**
|
||||
- `text`: (Default) The standard human-readable output.
|
||||
- `json`: A machine-readable JSON output.
|
||||
- **Note:** For structured output and scripting, use the `--output-format json` flag.
|
||||
- `json`: A machine-readable JSON output emitted at the end of execution.
|
||||
- `stream-json`: Streaming JSON messages emitted as they occur during execution.
|
||||
- **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless.md) for detailed information.
|
||||
- **`--input-format <format>`**:
|
||||
- **Description:** Specifies the format consumed from standard input.
|
||||
- **Values:**
|
||||
- `text`: (Default) Standard text input from stdin or command-line arguments.
|
||||
- `stream-json`: JSON message protocol via stdin for bidirectional communication.
|
||||
- **Requirement:** `--input-format stream-json` requires `--output-format stream-json` to be set.
|
||||
- **Note:** When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless.md) for detailed information.
|
||||
- **`--include-partial-messages`**:
|
||||
- **Description:** Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming.
|
||||
- **Default:** `false`
|
||||
- **Requirement:** Requires `--output-format stream-json` to be set.
|
||||
- **Note:** See [Headless Mode](../features/headless.md) for detailed information about stream events.
|
||||
- **`--sandbox`** (**`-s`**):
|
||||
- Enables sandbox mode for this session.
|
||||
- **`--sandbox-image`**:
|
||||
|
||||
@@ -13,8 +13,9 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
||||
- [Output Formats](#output-formats)
|
||||
- [Text Output (Default)](#text-output-default)
|
||||
- [JSON Output](#json-output)
|
||||
- [Response Schema](#response-schema)
|
||||
- [Example Usage](#example-usage)
|
||||
- [Stream-JSON Output](#stream-json-output)
|
||||
- [Input Format](#input-format)
|
||||
- [File Redirection](#file-redirection)
|
||||
- [Configuration Options](#configuration-options)
|
||||
- [Examples](#examples)
|
||||
@@ -22,7 +23,7 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools.
|
||||
- [Generate commit messages](#generate-commit-messages)
|
||||
- [API documentation](#api-documentation)
|
||||
- [Batch code analysis](#batch-code-analysis)
|
||||
- [Code review](#code-review-1)
|
||||
- [PR code review](#pr-code-review)
|
||||
- [Log analysis](#log-analysis)
|
||||
- [Release notes generation](#release-notes-generation)
|
||||
- [Model and tool usage tracking](#model-and-tool-usage-tracking)
|
||||
@@ -66,6 +67,8 @@ cat README.md | qwen --prompt "Summarize this documentation"
|
||||
|
||||
## Output Formats
|
||||
|
||||
Qwen Code supports multiple output formats for different use cases:
|
||||
|
||||
### Text Output (Default)
|
||||
|
||||
Standard human-readable output:
|
||||
@@ -82,56 +85,9 @@ The capital of France is Paris.
|
||||
|
||||
### JSON Output
|
||||
|
||||
Returns structured data including response, statistics, and metadata. This
|
||||
format is ideal for programmatic processing and automation scripts.
|
||||
Returns structured data as a JSON array. All messages are buffered and output together when the session completes. This format is ideal for programmatic processing and automation scripts.
|
||||
|
||||
#### Response Schema
|
||||
|
||||
The JSON output follows this high-level structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"response": "string", // The main AI-generated content answering your prompt
|
||||
"stats": {
|
||||
// Usage metrics and performance data
|
||||
"models": {
|
||||
// Per-model API and token usage statistics
|
||||
"[model-name]": {
|
||||
"api": {
|
||||
/* request counts, errors, latency */
|
||||
},
|
||||
"tokens": {
|
||||
/* prompt, response, cached, total counts */
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
// Tool execution statistics
|
||||
"totalCalls": "number",
|
||||
"totalSuccess": "number",
|
||||
"totalFail": "number",
|
||||
"totalDurationMs": "number",
|
||||
"totalDecisions": {
|
||||
/* accept, reject, modify, auto_accept counts */
|
||||
},
|
||||
"byName": {
|
||||
/* per-tool detailed stats */
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
// File modification statistics
|
||||
"totalLinesAdded": "number",
|
||||
"totalLinesRemoved": "number"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
// Present only when an error occurred
|
||||
"type": "string", // Error type (e.g., "ApiError", "AuthError")
|
||||
"message": "string", // Human-readable error description
|
||||
"code": "number" // Optional error code
|
||||
}
|
||||
}
|
||||
```
|
||||
The JSON output is an array of message objects. The output includes multiple message types: system messages (session initialization), assistant messages (AI responses), and result messages (execution summary).
|
||||
|
||||
#### Example Usage
|
||||
|
||||
@@ -139,63 +95,81 @@ The JSON output follows this high-level structure:
|
||||
qwen -p "What is the capital of France?" --output-format json
|
||||
```
|
||||
|
||||
Response:
|
||||
Output (at end of execution):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"response": "The capital of France is Paris.",
|
||||
"stats": {
|
||||
"models": {
|
||||
"qwen3-coder-plus": {
|
||||
"api": {
|
||||
"totalRequests": 2,
|
||||
"totalErrors": 0,
|
||||
"totalLatencyMs": 5053
|
||||
"type": "system",
|
||||
"subtype": "session_start",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"model": "qwen3-coder-plus",
|
||||
...
|
||||
},
|
||||
"tokens": {
|
||||
"prompt": 24939,
|
||||
"candidates": 20,
|
||||
"total": 25113,
|
||||
"cached": 21263,
|
||||
"thoughts": 154,
|
||||
"tool": 0
|
||||
}
|
||||
{
|
||||
"type": "assistant",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"message": {
|
||||
"id": "...",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "qwen3-coder-plus",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "The capital of France is Paris."
|
||||
}
|
||||
],
|
||||
"usage": {...}
|
||||
},
|
||||
"tools": {
|
||||
"totalCalls": 1,
|
||||
"totalSuccess": 1,
|
||||
"totalFail": 0,
|
||||
"totalDurationMs": 1881,
|
||||
"totalDecisions": {
|
||||
"accept": 0,
|
||||
"reject": 0,
|
||||
"modify": 0,
|
||||
"auto_accept": 1
|
||||
"parent_tool_use_id": null
|
||||
},
|
||||
"byName": {
|
||||
"google_web_search": {
|
||||
"count": 1,
|
||||
"success": 1,
|
||||
"fail": 0,
|
||||
"durationMs": 1881,
|
||||
"decisions": {
|
||||
"accept": 0,
|
||||
"reject": 0,
|
||||
"modify": 0,
|
||||
"auto_accept": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"totalLinesAdded": 0,
|
||||
"totalLinesRemoved": 0
|
||||
}
|
||||
}
|
||||
{
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"uuid": "...",
|
||||
"session_id": "...",
|
||||
"is_error": false,
|
||||
"duration_ms": 1234,
|
||||
"result": "The capital of France is Paris.",
|
||||
"usage": {...}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Stream-JSON Output
|
||||
|
||||
Stream-JSON format emits JSON messages immediately as they occur during execution, enabling real-time monitoring. This format uses line-delimited JSON where each message is a complete JSON object on a single line.
|
||||
|
||||
```bash
|
||||
qwen -p "Explain TypeScript" --output-format stream-json
|
||||
```
|
||||
|
||||
Output (streaming as events occur):
|
||||
|
||||
```json
|
||||
{"type":"system","subtype":"session_start","uuid":"...","session_id":"..."}
|
||||
{"type":"assistant","uuid":"...","session_id":"...","message":{...}}
|
||||
{"type":"result","subtype":"success","uuid":"...","session_id":"..."}
|
||||
```
|
||||
|
||||
When combined with `--include-partial-messages`, additional stream events are emitted in real-time (message_start, content_block_delta, etc.) for real-time UI updates.
|
||||
|
||||
```bash
|
||||
qwen -p "Write a Python script" --output-format stream-json --include-partial-messages
|
||||
```
|
||||
|
||||
### Input Format
|
||||
|
||||
The `--input-format` parameter controls how Qwen Code consumes input from standard input:
|
||||
|
||||
- **`text`** (default): Standard text input from stdin or command-line arguments
|
||||
- **`stream-json`**: JSON message protocol via stdin for bidirectional communication
|
||||
|
||||
> **Note:** Stream-json input mode is currently under construction and is intended for SDK integration. It requires `--output-format stream-json` to be set.
|
||||
|
||||
### File Redirection
|
||||
|
||||
Save output to files or pipe to other commands:
|
||||
@@ -212,6 +186,10 @@ qwen -p "Add more details" >> docker-explanation.txt
|
||||
qwen -p "What is Kubernetes?" --output-format json | jq '.response'
|
||||
qwen -p "Explain microservices" | wc -w
|
||||
qwen -p "List programming languages" | grep -i "python"
|
||||
|
||||
# Stream-JSON output for real-time processing
|
||||
qwen -p "Explain Docker" --output-format stream-json | jq '.type'
|
||||
qwen -p "Write code" --output-format stream-json --include-partial-messages | jq '.event.type'
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
@@ -219,10 +197,11 @@ qwen -p "List programming languages" | grep -i "python"
|
||||
Key command-line options for headless usage:
|
||||
|
||||
| Option | Description | Example |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------ |
|
||||
| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format` | Specify output format (text, json) | `qwen -p "query" --output-format json` |
|
||||
| `--model`, `-m` | Specify the Qwen model | `qwen -p "query" -m qwen3-coder-plus` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
@@ -233,27 +212,27 @@ For complete details on all available configuration options, settings files, and
|
||||
|
||||
## Examples
|
||||
|
||||
#### Code review
|
||||
### Code review
|
||||
|
||||
```bash
|
||||
cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt
|
||||
```
|
||||
|
||||
#### Generate commit messages
|
||||
### Generate commit messages
|
||||
|
||||
```bash
|
||||
result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json)
|
||||
echo "$result" | jq -r '.response'
|
||||
```
|
||||
|
||||
#### API documentation
|
||||
### API documentation
|
||||
|
||||
```bash
|
||||
result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json)
|
||||
echo "$result" | jq -r '.response' > openapi.json
|
||||
```
|
||||
|
||||
#### Batch code analysis
|
||||
### Batch code analysis
|
||||
|
||||
```bash
|
||||
for file in src/*.py; do
|
||||
@@ -264,20 +243,20 @@ for file in src/*.py; do
|
||||
done
|
||||
```
|
||||
|
||||
#### Code review
|
||||
### PR code review
|
||||
|
||||
```bash
|
||||
result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json)
|
||||
echo "$result" | jq -r '.response' > pr-review.json
|
||||
```
|
||||
|
||||
#### Log analysis
|
||||
### Log analysis
|
||||
|
||||
```bash
|
||||
grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt
|
||||
```
|
||||
|
||||
#### Release notes generation
|
||||
### Release notes generation
|
||||
|
||||
```bash
|
||||
result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json)
|
||||
@@ -286,7 +265,7 @@ echo "$response"
|
||||
echo "$response" >> CHANGELOG.md
|
||||
```
|
||||
|
||||
#### Model and tool usage tracking
|
||||
### Model and tool usage tracking
|
||||
|
||||
```bash
|
||||
result=$(qwen -p "Explain this database schema" --include-directories db --output-format json)
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('JSON output', () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it('should return a valid JSON with response and stats', async () => {
|
||||
it('should return a valid JSON array with result message containing response and stats', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
@@ -27,12 +27,30 @@ describe('JSON output', () => {
|
||||
);
|
||||
const parsed = JSON.parse(result);
|
||||
|
||||
expect(parsed).toHaveProperty('response');
|
||||
expect(typeof parsed.response).toBe('string');
|
||||
expect(parsed.response.toLowerCase()).toContain('paris');
|
||||
// The output should be an array of messages
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
|
||||
expect(parsed).toHaveProperty('stats');
|
||||
expect(typeof parsed.stats).toBe('object');
|
||||
// Find the result message (should be the last message)
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(typeof resultMessage.result).toBe('string');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
|
||||
// Stats may be present if available
|
||||
if ('stats' in resultMessage) {
|
||||
expect(typeof resultMessage.stats).toBe('object');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return a JSON error for enforced auth mismatch before running', async () => {
|
||||
@@ -56,32 +74,236 @@ describe('JSON output', () => {
|
||||
expect(thrown).toBeDefined();
|
||||
const message = (thrown as Error).message;
|
||||
|
||||
// Use a regex to find the first complete JSON object in the string
|
||||
const jsonMatch = message.match(/{[\s\S]*}/);
|
||||
|
||||
// Fail if no JSON-like text was found
|
||||
// The error JSON is written to stdout as a CLIResultMessageError
|
||||
// Extract stdout from the error message
|
||||
const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/);
|
||||
expect(
|
||||
jsonMatch,
|
||||
'Expected to find a JSON object in the error output',
|
||||
stdoutMatch,
|
||||
'Expected to find stdout in the error message',
|
||||
).toBeTruthy();
|
||||
|
||||
let payload;
|
||||
const stdout = stdoutMatch![1];
|
||||
let parsed: unknown[];
|
||||
try {
|
||||
// Parse the matched JSON string
|
||||
payload = JSON.parse(jsonMatch![0]);
|
||||
// Parse the JSON array from stdout
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse the following JSON:', jsonMatch![0]);
|
||||
console.error('Failed to parse the following JSON:', stdout);
|
||||
throw new Error(
|
||||
`Test failed: Could not parse JSON from error message. Details: ${parseError}`,
|
||||
`Test failed: Could not parse JSON from stdout. Details: ${parseError}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(payload.error).toBeDefined();
|
||||
expect(payload.error.type).toBe('Error');
|
||||
expect(payload.error.code).toBe(1);
|
||||
expect(payload.error.message).toContain(
|
||||
// The output should be an array of messages
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the result message with error
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result' &&
|
||||
'is_error' in msg &&
|
||||
msg.is_error === true,
|
||||
) as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
subtype: string;
|
||||
error?: { message: string; type?: string };
|
||||
};
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage.is_error).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('subtype');
|
||||
expect(resultMessage.subtype).toBe('error_during_execution');
|
||||
expect(resultMessage).toHaveProperty('error');
|
||||
expect(resultMessage.error).toBeDefined();
|
||||
expect(resultMessage.error?.message).toContain(
|
||||
'configured auth type is qwen-oauth',
|
||||
);
|
||||
expect(payload.error.message).toContain('current auth type is openai');
|
||||
expect(resultMessage.error?.message).toContain(
|
||||
'current auth type is openai',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return line-delimited JSON messages for stream-json output format', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
);
|
||||
|
||||
// Stream-json output is line-delimited JSON (one JSON object per line)
|
||||
const lines = result
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
|
||||
// Parse each line as a JSON object
|
||||
const messages: unknown[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
messages.push(parsed);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have at least system, assistant, and result messages
|
||||
expect(messages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Find system message
|
||||
const systemMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'system',
|
||||
);
|
||||
expect(systemMessage).toBeDefined();
|
||||
expect(systemMessage).toHaveProperty('subtype');
|
||||
expect(systemMessage).toHaveProperty('session_id');
|
||||
|
||||
// Find assistant message
|
||||
const assistantMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'assistant',
|
||||
);
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage).toHaveProperty('message');
|
||||
expect(assistantMessage).toHaveProperty('session_id');
|
||||
|
||||
// Find result message (should be the last message)
|
||||
const resultMessage = messages[messages.length - 1] as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
result: string;
|
||||
};
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(
|
||||
typeof resultMessage === 'object' &&
|
||||
resultMessage !== null &&
|
||||
'type' in resultMessage &&
|
||||
resultMessage.type === 'result',
|
||||
).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(typeof resultMessage.result).toBe('string');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
});
|
||||
|
||||
it('should include stream events when using stream-json with include-partial-messages', async () => {
|
||||
const result = await rig.run(
|
||||
'What is the capital of France?',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--include-partial-messages',
|
||||
);
|
||||
|
||||
// Stream-json output is line-delimited JSON (one JSON object per line)
|
||||
const lines = result
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
|
||||
// Parse each line as a JSON object
|
||||
const messages: unknown[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
messages.push(parsed);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON line: ${line}. Error: ${parseError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Should have more messages than without include-partial-messages
|
||||
// because we're including stream events
|
||||
expect(messages.length).toBeGreaterThan(3);
|
||||
|
||||
// Find stream_event messages
|
||||
const streamEvents = messages.filter(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'stream_event',
|
||||
);
|
||||
expect(streamEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify stream event structure
|
||||
const firstStreamEvent = streamEvents[0];
|
||||
expect(firstStreamEvent).toHaveProperty('event');
|
||||
expect(firstStreamEvent).toHaveProperty('session_id');
|
||||
expect(firstStreamEvent).toHaveProperty('uuid');
|
||||
|
||||
// Check for expected stream event types
|
||||
const eventTypes = streamEvents.map((event: unknown) =>
|
||||
typeof event === 'object' &&
|
||||
event !== null &&
|
||||
'event' in event &&
|
||||
typeof event.event === 'object' &&
|
||||
event.event !== null &&
|
||||
'type' in event.event
|
||||
? event.event.type
|
||||
: null,
|
||||
);
|
||||
|
||||
// Should have message_start event
|
||||
expect(eventTypes).toContain('message_start');
|
||||
|
||||
// Should have content_block_start event
|
||||
expect(eventTypes).toContain('content_block_start');
|
||||
|
||||
// Should have content_block_delta events
|
||||
expect(eventTypes).toContain('content_block_delta');
|
||||
|
||||
// Should have content_block_stop event
|
||||
expect(eventTypes).toContain('content_block_stop');
|
||||
|
||||
// Should have message_stop event
|
||||
expect(eventTypes).toContain('message_stop');
|
||||
|
||||
// Verify that we still have the complete assistant message
|
||||
const assistantMessage = messages.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'assistant',
|
||||
);
|
||||
expect(assistantMessage).toBeDefined();
|
||||
expect(assistantMessage).toHaveProperty('message');
|
||||
|
||||
// Verify that we still have the result message
|
||||
const resultMessage = messages[messages.length - 1] as {
|
||||
type: string;
|
||||
is_error: boolean;
|
||||
result: string;
|
||||
};
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(
|
||||
typeof resultMessage === 'object' &&
|
||||
resultMessage !== null &&
|
||||
'type' in resultMessage &&
|
||||
resultMessage.type === 'result',
|
||||
).toBe(true);
|
||||
expect(resultMessage).toHaveProperty('is_error');
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage).toHaveProperty('result');
|
||||
expect(resultMessage.result.toLowerCase()).toContain('paris');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,7 +340,8 @@ export class TestRig {
|
||||
// as it would corrupt the JSON
|
||||
const isJsonOutput =
|
||||
commandArgs.includes('--output-format') &&
|
||||
commandArgs.includes('json');
|
||||
(commandArgs.includes('json') ||
|
||||
commandArgs.includes('stream-json'));
|
||||
|
||||
// If we have stderr output and it's not a JSON test, include that also
|
||||
if (stderr && !isJsonOutput) {
|
||||
@@ -348,9 +349,25 @@ export class TestRig {
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
} else {
|
||||
// Check if this is a JSON output test - for JSON errors, the error is in stdout
|
||||
const isJsonOutputOnError =
|
||||
commandArgs.includes('--output-format') &&
|
||||
(commandArgs.includes('json') ||
|
||||
commandArgs.includes('stream-json'));
|
||||
|
||||
// For JSON output tests, include stdout in the error message
|
||||
// as the error JSON is written to stdout
|
||||
if (isJsonOutputOnError && stdout) {
|
||||
reject(
|
||||
new Error(
|
||||
`Process exited with code ${code}:\nStdout:\n${stdout}\n\nStderr:\n${stderr}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,9 +8,16 @@
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"qwen": "dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build_package.js",
|
||||
"start": "node dist/index.js",
|
||||
|
||||
@@ -392,6 +392,49 @@ describe('parseArguments', () => {
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw an error when include-partial-messages is used without stream-json output', async () => {
|
||||
process.argv = ['node', 'script.js', '--include-partial-messages'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'--include-partial-messages requires --output-format stream-json',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('should parse stream-json formats and include-partial-messages flag', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--include-partial-messages',
|
||||
];
|
||||
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
expect(argv.outputFormat).toBe('stream-json');
|
||||
expect(argv.inputFormat).toBe('stream-json');
|
||||
expect(argv.includePartialMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow --approval-mode without --yolo', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
@@ -473,6 +516,34 @@ describe('loadCliConfig', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should propagate stream-json formats to config', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--include-partial-messages',
|
||||
];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
[],
|
||||
new ExtensionEnablementManager(
|
||||
ExtensionStorage.getUserExtensionsDir(),
|
||||
argv.extensions,
|
||||
),
|
||||
'test-session',
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getOutputFormat()).toBe('stream-json');
|
||||
expect(config.getInputFormat()).toBe('stream-json');
|
||||
expect(config.getIncludePartialMessages()).toBe(true);
|
||||
});
|
||||
|
||||
it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => {
|
||||
process.argv = ['node', 'script.js', '--show-memory-usage'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type {
|
||||
FileFilteringOptions,
|
||||
MCPServerConfig,
|
||||
OutputFormat,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import {
|
||||
@@ -24,6 +23,8 @@ import {
|
||||
WriteFileTool,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import yargs, { type Argv } from 'yargs';
|
||||
@@ -124,7 +125,24 @@ export interface CliArgs {
|
||||
screenReader: boolean | undefined;
|
||||
vlmSwitchMode: string | undefined;
|
||||
useSmartEdit: boolean | undefined;
|
||||
inputFormat?: string | undefined;
|
||||
outputFormat: string | undefined;
|
||||
includePartialMessages?: boolean;
|
||||
}
|
||||
|
||||
function normalizeOutputFormat(
|
||||
format: string | OutputFormat | undefined,
|
||||
): OutputFormat | undefined {
|
||||
if (!format) {
|
||||
return undefined;
|
||||
}
|
||||
if (format === OutputFormat.STREAM_JSON) {
|
||||
return OutputFormat.STREAM_JSON;
|
||||
}
|
||||
if (format === 'json' || format === OutputFormat.JSON) {
|
||||
return OutputFormat.JSON;
|
||||
}
|
||||
return OutputFormat.TEXT;
|
||||
}
|
||||
|
||||
export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
@@ -359,11 +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: process.env['VLM_SWITCH_MODE'],
|
||||
})
|
||||
.option('input-format', {
|
||||
type: 'string',
|
||||
choices: ['text', 'stream-json'],
|
||||
description: 'The format consumed from standard input.',
|
||||
default: 'text',
|
||||
})
|
||||
.option('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
description: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
choices: ['text', 'json', 'stream-json'],
|
||||
})
|
||||
.option('include-partial-messages', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Include partial assistant messages when using stream-json output.',
|
||||
default: false,
|
||||
})
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
@@ -408,6 +438,18 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
if (argv['yolo'] && argv['approvalMode']) {
|
||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
||||
}
|
||||
if (
|
||||
argv['includePartialMessages'] &&
|
||||
argv['outputFormat'] !== OutputFormat.STREAM_JSON
|
||||
) {
|
||||
return '--include-partial-messages requires --output-format stream-json';
|
||||
}
|
||||
if (
|
||||
argv['inputFormat'] === 'stream-json' &&
|
||||
argv['outputFormat'] !== OutputFormat.STREAM_JSON
|
||||
) {
|
||||
return '--input-format stream-json requires --output-format stream-json';
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
)
|
||||
@@ -588,6 +630,22 @@ export async function loadCliConfig(
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
const inputFormat: InputFormat =
|
||||
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
|
||||
const argvOutputFormat = normalizeOutputFormat(
|
||||
argv.outputFormat as string | OutputFormat | undefined,
|
||||
);
|
||||
const settingsOutputFormat = normalizeOutputFormat(settings.output?.format);
|
||||
const outputFormat =
|
||||
argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT;
|
||||
const outputSettingsFormat: OutputFormat =
|
||||
outputFormat === OutputFormat.STREAM_JSON
|
||||
? settingsOutputFormat &&
|
||||
settingsOutputFormat !== OutputFormat.STREAM_JSON
|
||||
? settingsOutputFormat
|
||||
: OutputFormat.TEXT
|
||||
: (outputFormat as OutputFormat);
|
||||
const includePartialMessages = Boolean(argv.includePartialMessages);
|
||||
|
||||
// Determine approval mode with backward compatibility
|
||||
let approvalMode: ApprovalMode;
|
||||
@@ -629,11 +687,31 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
|
||||
// Interactive mode determination with priority:
|
||||
// 1. If promptInteractive (-i flag) is provided, it is explicitly interactive
|
||||
// 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive
|
||||
// 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive
|
||||
const hasQuery = !!argv.query;
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
|
||||
const hasPrompt = !!argv.prompt;
|
||||
let interactive: boolean;
|
||||
if (argv.promptInteractive) {
|
||||
// Priority 1: Explicit -i flag means interactive
|
||||
interactive = true;
|
||||
} else if (
|
||||
(outputFormat === OutputFormat.STREAM_JSON ||
|
||||
outputFormat === OutputFormat.JSON) &&
|
||||
(hasQuery || hasPrompt)
|
||||
) {
|
||||
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
|
||||
interactive = false;
|
||||
} else if (!hasQuery && !hasPrompt) {
|
||||
// Priority 3: No query or prompt means interactive only if TTY (format arguments ignored)
|
||||
interactive = process.stdin.isTTY ?? false;
|
||||
} else {
|
||||
// Default: If we have query/prompt but output format is TEXT, assume non-interactive
|
||||
// (fallback for edge cases where query/prompt is provided with TEXT output)
|
||||
interactive = false;
|
||||
}
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
const extraExcludes: string[] = [];
|
||||
if (!interactive && !argv.experimentalAcp) {
|
||||
@@ -755,6 +833,9 @@ export async function loadCliConfig(
|
||||
blockedMcpServers,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
authType: settings.security?.auth?.selectedType,
|
||||
inputFormat,
|
||||
outputFormat,
|
||||
includePartialMessages,
|
||||
generationConfig: {
|
||||
...(settings.model?.generationConfig || {}),
|
||||
model: resolvedModel,
|
||||
@@ -798,7 +879,7 @@ export async function loadCliConfig(
|
||||
eventEmitter: appEvents,
|
||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||
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 {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { type LoadedSettings } from './config/settings.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { OutputFormat } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Custom error to identify mock process.exit calls
|
||||
class MockProcessExitError extends Error {
|
||||
@@ -158,6 +159,7 @@ describe('gemini.tsx main function', () => {
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getOutputFormat: () => OutputFormat.TEXT,
|
||||
} as unknown as Config;
|
||||
});
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
@@ -230,6 +232,143 @@ describe('gemini.tsx main function', () => {
|
||||
// Avoid the process.exit error from being thrown.
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('invokes runNonInteractiveStreamJson and performs cleanup in stream-json mode', async () => {
|
||||
const originalIsTTY = Object.getOwnPropertyDescriptor(
|
||||
process.stdin,
|
||||
'isTTY',
|
||||
);
|
||||
const originalIsRaw = Object.getOwnPropertyDescriptor(
|
||||
process.stdin,
|
||||
'isRaw',
|
||||
);
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'isRaw', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const cleanupModule = await import('./utils/cleanup.js');
|
||||
const extensionModule = await import('./config/extension.js');
|
||||
const validatorModule = await import('./validateNonInterActiveAuth.js');
|
||||
const streamJsonModule = await import('./nonInteractive/session.js');
|
||||
const initializerModule = await import('./core/initializer.js');
|
||||
const startupWarningsModule = await import('./utils/startupWarnings.js');
|
||||
const userStartupWarningsModule = await import(
|
||||
'./utils/userStartupWarnings.js'
|
||||
);
|
||||
|
||||
vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);
|
||||
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
|
||||
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
|
||||
runExitCleanupMock.mockResolvedValue(undefined);
|
||||
vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]);
|
||||
vi.spyOn(
|
||||
extensionModule.ExtensionStorage,
|
||||
'getUserExtensionsDir',
|
||||
).mockReturnValue('/tmp/extensions');
|
||||
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
});
|
||||
vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);
|
||||
vi.spyOn(
|
||||
userStartupWarningsModule,
|
||||
'getUserStartupWarnings',
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const validatedConfig = { validated: true } as unknown as Config;
|
||||
const validateAuthSpy = vi
|
||||
.spyOn(validatorModule, 'validateNonInteractiveAuth')
|
||||
.mockResolvedValue(validatedConfig);
|
||||
const runStreamJsonSpy = vi
|
||||
.spyOn(streamJsonModule, 'runNonInteractiveStreamJson')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
errors: [],
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
} as never);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
extensions: [],
|
||||
} as never);
|
||||
|
||||
const configStub = {
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => ' hello stream ',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getMcpServers: () => ({}),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getInputFormat: () => 'stream-json',
|
||||
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
|
||||
|
||||
process.env['SANDBOX'] = '1';
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
if (!(error instanceof MockProcessExitError)) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
processExitSpy.mockRestore();
|
||||
if (originalIsTTY) {
|
||||
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
|
||||
} else {
|
||||
delete (process.stdin as { isTTY?: unknown }).isTTY;
|
||||
}
|
||||
if (originalIsRaw) {
|
||||
Object.defineProperty(process.stdin, 'isRaw', originalIsRaw);
|
||||
} else {
|
||||
delete (process.stdin as { isRaw?: unknown }).isRaw;
|
||||
}
|
||||
delete process.env['SANDBOX'];
|
||||
}
|
||||
|
||||
expect(runStreamJsonSpy).toHaveBeenCalledTimes(1);
|
||||
const [configArg, inputArg] = runStreamJsonSpy.mock.calls[0];
|
||||
expect(configArg).toBe(validatedConfig);
|
||||
expect(inputArg).toBe('hello stream');
|
||||
|
||||
expect(validateAuthSpy).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
configStub,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini.tsx main function kitty protocol', () => {
|
||||
@@ -337,7 +476,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
screenReader: undefined,
|
||||
vlmSwitchMode: undefined,
|
||||
useSmartEdit: undefined,
|
||||
inputFormat: undefined,
|
||||
outputFormat: undefined,
|
||||
includePartialMessages: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
@@ -412,6 +553,7 @@ describe('startInteractiveUI', () => {
|
||||
vi.mock('./utils/cleanup.js', () => ({
|
||||
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
|
||||
registerCleanup: vi.fn(),
|
||||
runExitCleanup: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock('ink', () => ({
|
||||
|
||||
@@ -4,58 +4,60 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
InputFormat,
|
||||
logUserPrompt,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { render } from 'ink';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import dns from 'node:dns';
|
||||
import os from 'node:os';
|
||||
import { basename } from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import React from 'react';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
|
||||
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
|
||||
import { themeManager } from './ui/themes/theme-manager.js';
|
||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
} from './core/initializer.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { ExtensionStorage, loadExtensions } from './config/extension.js';
|
||||
import { runNonInteractiveStreamJson } from './nonInteractive/session.js';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { themeManager } from './ui/themes/theme-manager.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import {
|
||||
cleanupCheckpoints,
|
||||
registerCleanup,
|
||||
runExitCleanup,
|
||||
} from './utils/cleanup.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
logUserPrompt,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
} from './core/initializer.js';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { AppEvent, appEvents } from './utils/events.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import {
|
||||
relaunchOnExitCode,
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
} from './utils/relaunch.js';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
@@ -106,9 +108,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
@@ -218,12 +220,6 @@ export async function main() {
|
||||
}
|
||||
|
||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: isDebugMode,
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
dns.setDefaultResultOrder(
|
||||
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
|
||||
@@ -348,6 +344,15 @@ export async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Setup unified ConsolePatcher based on interactive mode
|
||||
const isInteractive = config.isInteractive();
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: isInteractive,
|
||||
debugMode: isDebugMode,
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
|
||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||
@@ -410,14 +415,43 @@ export async function main() {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
// If not a TTY, read from stdin
|
||||
// This is for cases where the user pipes input directly into the command
|
||||
if (!process.stdin.isTTY) {
|
||||
// Check input format BEFORE reading stdin
|
||||
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
|
||||
const inputFormat =
|
||||
typeof config.getInputFormat === 'function'
|
||||
? config.getInputFormat()
|
||||
: InputFormat.TEXT;
|
||||
|
||||
// Only read stdin if NOT in stream-json mode
|
||||
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
|
||||
// and should be consumed by StreamJsonInputReader instead
|
||||
if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) {
|
||||
const stdinData = await readStdin();
|
||||
if (stdinData) {
|
||||
input = `${stdinData}\n\n${input}`;
|
||||
}
|
||||
}
|
||||
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
|
||||
const prompt_id = Math.random().toString(16).slice(2);
|
||||
|
||||
if (inputFormat === InputFormat.STREAM_JSON) {
|
||||
const trimmedInput = (input ?? '').trim();
|
||||
|
||||
await runNonInteractiveStreamJson(
|
||||
nonInteractiveConfig,
|
||||
trimmedInput.length > 0 ? trimmedInput : '',
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
console.error(
|
||||
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
|
||||
@@ -425,7 +459,6 @@ export async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const prompt_id = Math.random().toString(16).slice(2);
|
||||
logUserPrompt(config, {
|
||||
'event.name': 'user_prompt',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
@@ -435,13 +468,6 @@ export async function main() {
|
||||
prompt_length: input.length,
|
||||
});
|
||||
|
||||
const nonInteractiveConfig = await validateNonInteractiveAuth(
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
settings.merged.security?.auth?.useExternal,
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
|
||||
if (config.getDebugMode()) {
|
||||
console.log('Session ID: %s', sessionId);
|
||||
}
|
||||
|
||||
76
packages/cli/src/nonInteractive/control/ControlContext.ts
Normal file
76
packages/cli/src/nonInteractive/control/ControlContext.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Context
|
||||
*
|
||||
* Layer 1 of the control plane architecture. Provides shared, session-scoped
|
||||
* state for all controllers and services, eliminating the need for prop
|
||||
* drilling. Mutable fields are intentionally exposed so controllers can track
|
||||
* runtime state (e.g. permission mode, active MCP clients).
|
||||
*/
|
||||
|
||||
import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
|
||||
import type { PermissionMode } from '../types.js';
|
||||
|
||||
/**
|
||||
* Control Context interface
|
||||
*
|
||||
* Provides shared access to session-scoped resources and mutable state
|
||||
* for all controllers across both ControlDispatcher (protocol routing) and
|
||||
* ControlService (programmatic API).
|
||||
*/
|
||||
export interface IControlContext {
|
||||
readonly config: Config;
|
||||
readonly streamJson: StreamJsonOutputAdapter;
|
||||
readonly sessionId: string;
|
||||
readonly abortSignal: AbortSignal;
|
||||
readonly debugMode: boolean;
|
||||
|
||||
permissionMode: PermissionMode;
|
||||
sdkMcpServers: Set<string>;
|
||||
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
|
||||
|
||||
onInterrupt?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Control Context implementation
|
||||
*/
|
||||
export class ControlContext implements IControlContext {
|
||||
readonly config: Config;
|
||||
readonly streamJson: StreamJsonOutputAdapter;
|
||||
readonly sessionId: string;
|
||||
readonly abortSignal: AbortSignal;
|
||||
readonly debugMode: boolean;
|
||||
|
||||
permissionMode: PermissionMode;
|
||||
sdkMcpServers: Set<string>;
|
||||
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
|
||||
|
||||
onInterrupt?: () => void;
|
||||
|
||||
constructor(options: {
|
||||
config: Config;
|
||||
streamJson: StreamJsonOutputAdapter;
|
||||
sessionId: string;
|
||||
abortSignal: AbortSignal;
|
||||
permissionMode?: PermissionMode;
|
||||
onInterrupt?: () => void;
|
||||
}) {
|
||||
this.config = options.config;
|
||||
this.streamJson = options.streamJson;
|
||||
this.sessionId = options.sessionId;
|
||||
this.abortSignal = options.abortSignal;
|
||||
this.debugMode = options.config.getDebugMode();
|
||||
this.permissionMode = options.permissionMode || 'default';
|
||||
this.sdkMcpServers = new Set();
|
||||
this.mcpClients = new Map();
|
||||
this.onInterrupt = options.onInterrupt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ControlDispatcher } from './ControlDispatcher.js';
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { SystemController } from './controllers/systemController.js';
|
||||
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlResponse,
|
||||
ControlRequestPayload,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlInterruptRequest,
|
||||
CLIControlSetModelRequest,
|
||||
CLIControlSupportedCommandsRequest,
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* Creates a mock control context for testing
|
||||
*/
|
||||
function createMockContext(debugMode: boolean = false): IControlContext {
|
||||
const abortController = new AbortController();
|
||||
const mockStreamJson = {
|
||||
send: vi.fn(),
|
||||
} as unknown as StreamJsonOutputAdapter;
|
||||
|
||||
const mockConfig = {
|
||||
getDebugMode: vi.fn().mockReturnValue(debugMode),
|
||||
};
|
||||
|
||||
return {
|
||||
config: mockConfig as unknown as IControlContext['config'],
|
||||
streamJson: mockStreamJson,
|
||||
sessionId: 'test-session-id',
|
||||
abortSignal: abortController.signal,
|
||||
debugMode,
|
||||
permissionMode: 'default',
|
||||
sdkMcpServers: new Set<string>(),
|
||||
mcpClients: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock system controller for testing
|
||||
*/
|
||||
function createMockSystemController() {
|
||||
return {
|
||||
handleRequest: vi.fn(),
|
||||
sendControlRequest: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
} as unknown as SystemController;
|
||||
}
|
||||
|
||||
describe('ControlDispatcher', () => {
|
||||
let dispatcher: ControlDispatcher;
|
||||
let mockContext: IControlContext;
|
||||
let mockSystemController: SystemController;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockContext();
|
||||
mockSystemController = createMockSystemController();
|
||||
|
||||
// Mock SystemController constructor
|
||||
vi.doMock('./controllers/systemController.js', () => ({
|
||||
SystemController: vi.fn().mockImplementation(() => mockSystemController),
|
||||
}));
|
||||
|
||||
dispatcher = new ControlDispatcher(mockContext);
|
||||
// Replace with mock controller for easier testing
|
||||
(
|
||||
dispatcher as unknown as { systemController: SystemController }
|
||||
).systemController = mockSystemController;
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with context and create controllers', () => {
|
||||
expect(dispatcher).toBeDefined();
|
||||
expect(dispatcher.systemController).toBeDefined();
|
||||
});
|
||||
|
||||
it('should listen to abort signal and shutdown when aborted', () => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const context = {
|
||||
...createMockContext(),
|
||||
abortSignal: abortController.signal,
|
||||
};
|
||||
|
||||
const newDispatcher = new ControlDispatcher(context);
|
||||
vi.spyOn(newDispatcher, 'shutdown');
|
||||
|
||||
abortController.abort();
|
||||
|
||||
// Give event loop a chance to process
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
expect(newDispatcher.shutdown).toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch', () => {
|
||||
it('should route initialize request to system controller', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
} as CLIControlInitializeRequest,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
subtype: 'initialize',
|
||||
capabilities: { test: true },
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||
request.request,
|
||||
'req-1',
|
||||
);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-1',
|
||||
response: mockResponse,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route interrupt request to system controller', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-2',
|
||||
request: {
|
||||
subtype: 'interrupt',
|
||||
} as CLIControlInterruptRequest,
|
||||
};
|
||||
|
||||
const mockResponse = { subtype: 'interrupt' };
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||
request.request,
|
||||
'req-2',
|
||||
);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-2',
|
||||
response: mockResponse,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route set_model request to system controller', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-3',
|
||||
request: {
|
||||
subtype: 'set_model',
|
||||
model: 'test-model',
|
||||
} as CLIControlSetModelRequest,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
subtype: 'set_model',
|
||||
model: 'test-model',
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||
request.request,
|
||||
'req-3',
|
||||
);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-3',
|
||||
response: mockResponse,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route supported_commands request to system controller', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-4',
|
||||
request: {
|
||||
subtype: 'supported_commands',
|
||||
} as CLIControlSupportedCommandsRequest,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
subtype: 'supported_commands',
|
||||
commands: ['initialize', 'interrupt'],
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
|
||||
mockResponse,
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
|
||||
request.request,
|
||||
'req-4',
|
||||
);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-4',
|
||||
response: mockResponse,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send error response when controller throws error', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-5',
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
} as CLIControlInitializeRequest,
|
||||
};
|
||||
|
||||
const error = new Error('Test error');
|
||||
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(error);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-5',
|
||||
error: 'Test error',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error thrown values', async () => {
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-6',
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
} as CLIControlInitializeRequest,
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(
|
||||
'String error',
|
||||
);
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-6',
|
||||
error: 'String error',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should send error response for unknown request subtype', async () => {
|
||||
const request = {
|
||||
type: 'control_request' as const,
|
||||
request_id: 'req-7',
|
||||
request: {
|
||||
subtype: 'unknown_subtype',
|
||||
} as unknown as ControlRequestPayload,
|
||||
};
|
||||
|
||||
await dispatcher.dispatch(request);
|
||||
|
||||
// Dispatch catches errors and sends error response instead of throwing
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: 'req-7',
|
||||
error: 'Unknown control request subtype: unknown_subtype',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleControlResponse', () => {
|
||||
it('should resolve pending outgoing request on success response', () => {
|
||||
const requestId = 'outgoing-req-1';
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: { result: 'success' },
|
||||
},
|
||||
};
|
||||
|
||||
// Register a pending outgoing request
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
// Access private method through type casting
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith(response.response);
|
||||
expect(reject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject pending outgoing request on error response', () => {
|
||||
const requestId = 'outgoing-req-2';
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error: 'Request failed',
|
||||
},
|
||||
};
|
||||
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
|
||||
expect(reject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Request failed',
|
||||
}),
|
||||
);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle error object in error response', () => {
|
||||
const requestId = 'outgoing-req-3';
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error: { message: 'Detailed error', code: 500 },
|
||||
},
|
||||
};
|
||||
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
|
||||
expect(reject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Detailed error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle response for non-existent pending request gracefully', () => {
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'non-existent',
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
expect(() => dispatcher.handleControlResponse(response)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle response for non-existent request in debug mode', () => {
|
||||
const context = createMockContext(true);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'non-existent',
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
dispatcherWithDebug.handleControlResponse(response);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[ControlDispatcher] No pending outgoing request for: non-existent',
|
||||
),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendControlRequest', () => {
|
||||
it('should delegate to system controller sendControlRequest', async () => {
|
||||
const payload: ControlRequestPayload = {
|
||||
subtype: 'initialize',
|
||||
} as CLIControlInitializeRequest;
|
||||
|
||||
const expectedResponse: ControlResponse = {
|
||||
subtype: 'success',
|
||||
request_id: 'test-id',
|
||||
response: {},
|
||||
};
|
||||
|
||||
vi.mocked(mockSystemController.sendControlRequest).mockResolvedValue(
|
||||
expectedResponse,
|
||||
);
|
||||
|
||||
const result = await dispatcher.sendControlRequest(payload, 5000);
|
||||
|
||||
expect(mockSystemController.sendControlRequest).toHaveBeenCalledWith(
|
||||
payload,
|
||||
5000,
|
||||
);
|
||||
expect(result).toBe(expectedResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCancel', () => {
|
||||
it('should cancel specific incoming request', () => {
|
||||
const requestId = 'cancel-req-1';
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
const abortSpy = vi.spyOn(abortController, 'abort');
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
abortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.handleCancel(requestId);
|
||||
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error: 'Request cancelled',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should cancel all incoming requests when no requestId provided', () => {
|
||||
const requestId1 = 'cancel-req-2';
|
||||
const requestId2 = 'cancel-req-3';
|
||||
|
||||
const abortController1 = new AbortController();
|
||||
const abortController2 = new AbortController();
|
||||
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||
|
||||
const abortSpy1 = vi.spyOn(abortController1, 'abort');
|
||||
const abortSpy2 = vi.spyOn(abortController2, 'abort');
|
||||
|
||||
const register = (
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest.bind(dispatcher);
|
||||
|
||||
register(requestId1, 'SystemController', abortController1, timeoutId1);
|
||||
register(requestId2, 'SystemController', abortController2, timeoutId2);
|
||||
|
||||
dispatcher.handleCancel();
|
||||
|
||||
expect(abortSpy1).toHaveBeenCalled();
|
||||
expect(abortSpy2).toHaveBeenCalled();
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledTimes(2);
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId1,
|
||||
error: 'All requests cancelled',
|
||||
},
|
||||
});
|
||||
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId2,
|
||||
error: 'All requests cancelled',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle cancel of non-existent request gracefully', () => {
|
||||
expect(() => dispatcher.handleCancel('non-existent')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should log cancellation in debug mode', () => {
|
||||
const context = createMockContext(true);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||
const requestId = 'cancel-req-debug';
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcherWithDebug as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
abortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcherWithDebug.handleCancel(requestId);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[ControlDispatcher] Cancelled incoming request: cancel-req-debug',
|
||||
),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should cancel all pending incoming requests', () => {
|
||||
const requestId1 = 'shutdown-req-1';
|
||||
const requestId2 = 'shutdown-req-2';
|
||||
|
||||
const abortController1 = new AbortController();
|
||||
const abortController2 = new AbortController();
|
||||
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||
|
||||
const abortSpy1 = vi.spyOn(abortController1, 'abort');
|
||||
const abortSpy2 = vi.spyOn(abortController2, 'abort');
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
const register = (
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest.bind(dispatcher);
|
||||
|
||||
register(requestId1, 'SystemController', abortController1, timeoutId1);
|
||||
register(requestId2, 'SystemController', abortController2, timeoutId2);
|
||||
|
||||
dispatcher.shutdown();
|
||||
|
||||
expect(abortSpy1).toHaveBeenCalled();
|
||||
expect(abortSpy2).toHaveBeenCalled();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
|
||||
});
|
||||
|
||||
it('should reject all pending outgoing requests', () => {
|
||||
const requestId1 = 'outgoing-shutdown-1';
|
||||
const requestId2 = 'outgoing-shutdown-2';
|
||||
|
||||
const reject1 = vi.fn();
|
||||
const reject2 = vi.fn();
|
||||
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
const register = (
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest.bind(dispatcher);
|
||||
|
||||
register(requestId1, 'SystemController', vi.fn(), reject1, timeoutId1);
|
||||
register(requestId2, 'SystemController', vi.fn(), reject2, timeoutId2);
|
||||
|
||||
dispatcher.shutdown();
|
||||
|
||||
expect(reject1).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Dispatcher shutdown',
|
||||
}),
|
||||
);
|
||||
expect(reject2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Dispatcher shutdown',
|
||||
}),
|
||||
);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
|
||||
});
|
||||
|
||||
it('should cleanup all controllers', () => {
|
||||
vi.mocked(mockSystemController.cleanup).mockImplementation(() => {});
|
||||
|
||||
dispatcher.shutdown();
|
||||
|
||||
expect(mockSystemController.cleanup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log shutdown in debug mode', () => {
|
||||
const context = createMockContext(true);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||
|
||||
dispatcherWithDebug.shutdown();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[ControlDispatcher] Shutting down',
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending request registry', () => {
|
||||
describe('registerIncomingRequest', () => {
|
||||
it('should register incoming request', () => {
|
||||
const requestId = 'reg-incoming-1';
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerIncomingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
abortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
// Verify it was registered by trying to cancel it
|
||||
dispatcher.handleCancel(requestId);
|
||||
expect(abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deregisterIncomingRequest', () => {
|
||||
it('should deregister incoming request', () => {
|
||||
const requestId = 'dereg-incoming-1';
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerIncomingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
deregisterIncomingRequest: (id: string) => void;
|
||||
}
|
||||
).registerIncomingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
abortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
deregisterIncomingRequest: (id: string) => void;
|
||||
}
|
||||
).deregisterIncomingRequest(requestId);
|
||||
|
||||
// Verify it was deregistered - cancel should not find it
|
||||
const sendMock = vi.mocked(mockContext.streamJson.send);
|
||||
const sendCallCount = sendMock.mock.calls.length;
|
||||
dispatcher.handleCancel(requestId);
|
||||
// Should not send cancel response for non-existent request
|
||||
expect(sendMock.mock.calls.length).toBe(sendCallCount);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
|
||||
});
|
||||
|
||||
it('should handle deregister of non-existent request gracefully', () => {
|
||||
expect(() => {
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
deregisterIncomingRequest: (id: string) => void;
|
||||
}
|
||||
).deregisterIncomingRequest('non-existent');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerOutgoingRequest', () => {
|
||||
it('should register outgoing request', () => {
|
||||
const requestId = 'reg-outgoing-1';
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
// Verify it was registered by handling a response
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
expect(resolve).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deregisterOutgoingRequest', () => {
|
||||
it('should deregister outgoing request', () => {
|
||||
const requestId = 'dereg-outgoing-1';
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (r: ControlResponse) => void,
|
||||
reject: (e: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
deregisterOutgoingRequest: (id: string) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
deregisterOutgoingRequest: (id: string) => void;
|
||||
}
|
||||
).deregisterOutgoingRequest(requestId);
|
||||
|
||||
// Verify it was deregistered - response should not find it
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
|
||||
dispatcher.handleControlResponse(response);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
|
||||
});
|
||||
|
||||
it('should handle deregister of non-existent request gracefully', () => {
|
||||
expect(() => {
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
deregisterOutgoingRequest: (id: string) => void;
|
||||
}
|
||||
).deregisterOutgoingRequest('non-existent');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
353
packages/cli/src/nonInteractive/control/ControlDispatcher.ts
Normal file
353
packages/cli/src/nonInteractive/control/ControlDispatcher.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Dispatcher
|
||||
*
|
||||
* Layer 2 of the control plane architecture. Routes control requests between
|
||||
* SDK and CLI to appropriate controllers, manages pending request registries,
|
||||
* and handles cancellation/cleanup. Application code MUST NOT depend on
|
||||
* controller instances exposed by this class; instead, use ControlService,
|
||||
* which wraps these controllers with a stable programmatic API.
|
||||
*
|
||||
* Controllers:
|
||||
* - SystemController: initialize, interrupt, set_model, supported_commands
|
||||
* - PermissionController: can_use_tool, set_permission_mode
|
||||
* - MCPController: mcp_message, mcp_server_status
|
||||
* - HookController: hook_callback
|
||||
*
|
||||
* Note: Control request types are centrally defined in the ControlRequestType
|
||||
* enum in packages/sdk/typescript/src/types/controlRequests.ts
|
||||
*/
|
||||
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { IPendingRequestRegistry } from './controllers/baseController.js';
|
||||
import { SystemController } from './controllers/systemController.js';
|
||||
// import { PermissionController } from './controllers/permissionController.js';
|
||||
// import { MCPController } from './controllers/mcpController.js';
|
||||
// import { HookController } from './controllers/hookController.js';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlResponse,
|
||||
ControlRequestPayload,
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* Tracks an incoming request from SDK awaiting CLI response
|
||||
*/
|
||||
interface PendingIncomingRequest {
|
||||
controller: string;
|
||||
abortController: AbortController;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks an outgoing request from CLI awaiting SDK response
|
||||
*/
|
||||
interface PendingOutgoingRequest {
|
||||
controller: string;
|
||||
resolve: (response: ControlResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central coordinator for control plane communication.
|
||||
* Routes requests to controllers and manages request lifecycle.
|
||||
*/
|
||||
export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
private context: IControlContext;
|
||||
|
||||
// Make controllers publicly accessible
|
||||
readonly systemController: SystemController;
|
||||
// readonly permissionController: PermissionController;
|
||||
// readonly mcpController: MCPController;
|
||||
// readonly hookController: HookController;
|
||||
|
||||
// Central pending request registries
|
||||
private pendingIncomingRequests: Map<string, PendingIncomingRequest> =
|
||||
new Map();
|
||||
private pendingOutgoingRequests: Map<string, PendingOutgoingRequest> =
|
||||
new Map();
|
||||
|
||||
constructor(context: IControlContext) {
|
||||
this.context = context;
|
||||
|
||||
// Create domain controllers with context and registry
|
||||
this.systemController = new SystemController(
|
||||
context,
|
||||
this,
|
||||
'SystemController',
|
||||
);
|
||||
// this.permissionController = new PermissionController(
|
||||
// context,
|
||||
// this,
|
||||
// 'PermissionController',
|
||||
// );
|
||||
// this.mcpController = new MCPController(context, this, 'MCPController');
|
||||
// this.hookController = new HookController(context, this, 'HookController');
|
||||
|
||||
// Listen for main abort signal
|
||||
this.context.abortSignal.addEventListener('abort', () => {
|
||||
this.shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an incoming request to the appropriate controller and sends response
|
||||
*/
|
||||
async dispatch(request: CLIControlRequest): Promise<void> {
|
||||
const { request_id, request: payload } = request;
|
||||
|
||||
try {
|
||||
// Route to appropriate controller
|
||||
const controller = this.getControllerForRequest(payload.subtype);
|
||||
const response = await controller.handleRequest(payload, request_id);
|
||||
|
||||
// Send success response
|
||||
this.sendSuccessResponse(request_id, response);
|
||||
} catch (error) {
|
||||
// Send error response
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.sendErrorResponse(request_id, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes response from SDK for an outgoing request
|
||||
*/
|
||||
handleControlResponse(response: CLIControlResponse): void {
|
||||
const responsePayload = response.response;
|
||||
const requestId = responsePayload.request_id;
|
||||
|
||||
const pending = this.pendingOutgoingRequests.get(requestId);
|
||||
if (!pending) {
|
||||
// No pending request found - may have timed out or been cancelled
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] No pending outgoing request for: ${requestId}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Deregister
|
||||
this.deregisterOutgoingRequest(requestId);
|
||||
|
||||
// Resolve or reject based on response type
|
||||
if (responsePayload.subtype === 'success') {
|
||||
pending.resolve(responsePayload);
|
||||
} else {
|
||||
const errorMessage =
|
||||
typeof responsePayload.error === 'string'
|
||||
? responsePayload.error
|
||||
: (responsePayload.error?.message ?? 'Unknown error');
|
||||
pending.reject(new Error(errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a control request to SDK and waits for response
|
||||
*/
|
||||
async sendControlRequest(
|
||||
payload: ControlRequestPayload,
|
||||
timeoutMs?: number,
|
||||
): Promise<ControlResponse> {
|
||||
// Delegate to system controller (or any controller, they all have the same method)
|
||||
return this.systemController.sendControlRequest(payload, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a specific request or all pending requests
|
||||
*/
|
||||
handleCancel(requestId?: string): void {
|
||||
if (requestId) {
|
||||
// Cancel specific incoming request
|
||||
const pending = this.pendingIncomingRequests.get(requestId);
|
||||
if (pending) {
|
||||
pending.abortController.abort();
|
||||
this.deregisterIncomingRequest(requestId);
|
||||
this.sendErrorResponse(requestId, 'Request cancelled');
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] Cancelled incoming request: ${requestId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Cancel ALL pending incoming requests
|
||||
const requestIds = Array.from(this.pendingIncomingRequests.keys());
|
||||
for (const id of requestIds) {
|
||||
const pending = this.pendingIncomingRequests.get(id);
|
||||
if (pending) {
|
||||
pending.abortController.abort();
|
||||
this.deregisterIncomingRequest(id);
|
||||
this.sendErrorResponse(id, 'All requests cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops all pending requests and cleans up all controllers
|
||||
*/
|
||||
shutdown(): void {
|
||||
if (this.context.debugMode) {
|
||||
console.error('[ControlDispatcher] Shutting down');
|
||||
}
|
||||
|
||||
// Cancel all incoming requests
|
||||
for (const [
|
||||
_requestId,
|
||||
pending,
|
||||
] of this.pendingIncomingRequests.entries()) {
|
||||
pending.abortController.abort();
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
this.pendingIncomingRequests.clear();
|
||||
|
||||
// Cancel all outgoing requests
|
||||
for (const [
|
||||
_requestId,
|
||||
pending,
|
||||
] of this.pendingOutgoingRequests.entries()) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
pending.reject(new Error('Dispatcher shutdown'));
|
||||
}
|
||||
this.pendingOutgoingRequests.clear();
|
||||
|
||||
// Cleanup controllers (MCP controller will close all clients)
|
||||
this.systemController.cleanup();
|
||||
// this.permissionController.cleanup();
|
||||
// this.mcpController.cleanup();
|
||||
// this.hookController.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an incoming request in the pending registry
|
||||
*/
|
||||
registerIncomingRequest(
|
||||
requestId: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
): void {
|
||||
this.pendingIncomingRequests.set(requestId, {
|
||||
controller,
|
||||
abortController,
|
||||
timeoutId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an incoming request from the pending registry
|
||||
*/
|
||||
deregisterIncomingRequest(requestId: string): void {
|
||||
const pending = this.pendingIncomingRequests.get(requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingIncomingRequests.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an outgoing request in the pending registry
|
||||
*/
|
||||
registerOutgoingRequest(
|
||||
requestId: string,
|
||||
controller: string,
|
||||
resolve: (response: ControlResponse) => void,
|
||||
reject: (error: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
): void {
|
||||
this.pendingOutgoingRequests.set(requestId, {
|
||||
controller,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an outgoing request from the pending registry
|
||||
*/
|
||||
deregisterOutgoingRequest(requestId: string): void {
|
||||
const pending = this.pendingOutgoingRequests.get(requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingOutgoingRequests.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the controller that handles the given request subtype
|
||||
*/
|
||||
private getControllerForRequest(subtype: string) {
|
||||
switch (subtype) {
|
||||
case 'initialize':
|
||||
case 'interrupt':
|
||||
case 'set_model':
|
||||
case 'supported_commands':
|
||||
return this.systemController;
|
||||
|
||||
// case 'can_use_tool':
|
||||
// case 'set_permission_mode':
|
||||
// return this.permissionController;
|
||||
|
||||
// case 'mcp_message':
|
||||
// case 'mcp_server_status':
|
||||
// return this.mcpController;
|
||||
|
||||
// case 'hook_callback':
|
||||
// return this.hookController;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown control request subtype: ${subtype}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a success response back to SDK
|
||||
*/
|
||||
private sendSuccessResponse(
|
||||
requestId: string,
|
||||
response: Record<string, unknown>,
|
||||
): void {
|
||||
const controlResponse: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response,
|
||||
},
|
||||
};
|
||||
this.context.streamJson.send(controlResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an error response back to SDK
|
||||
*/
|
||||
private sendErrorResponse(requestId: string, error: string): void {
|
||||
const controlResponse: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error,
|
||||
},
|
||||
};
|
||||
this.context.streamJson.send(controlResponse);
|
||||
}
|
||||
}
|
||||
191
packages/cli/src/nonInteractive/control/ControlService.ts
Normal file
191
packages/cli/src/nonInteractive/control/ControlService.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Service - Public Programmatic API
|
||||
*
|
||||
* Provides type-safe access to control plane functionality for internal
|
||||
* CLI code. This is the ONLY programmatic interface that should be used by:
|
||||
* - nonInteractiveCli
|
||||
* - Session managers
|
||||
* - Tool execution handlers
|
||||
* - Internal CLI logic
|
||||
*
|
||||
* DO NOT use ControlDispatcher or controllers directly from application code.
|
||||
*
|
||||
* Architecture:
|
||||
* - ControlContext stores shared session state (Layer 1)
|
||||
* - ControlDispatcher handles protocol-level routing (Layer 2)
|
||||
* - ControlService provides programmatic API for internal CLI usage (Layer 3)
|
||||
*
|
||||
* ControlService and ControlDispatcher share controller instances to ensure
|
||||
* a single source of truth. All higher level code MUST access the control
|
||||
* plane exclusively through ControlService.
|
||||
*/
|
||||
|
||||
import type { IControlContext } from './ControlContext.js';
|
||||
import type { ControlDispatcher } from './ControlDispatcher.js';
|
||||
import type {
|
||||
// PermissionServiceAPI,
|
||||
SystemServiceAPI,
|
||||
// McpServiceAPI,
|
||||
// HookServiceAPI,
|
||||
} from './types/serviceAPIs.js';
|
||||
|
||||
/**
|
||||
* Control Service
|
||||
*
|
||||
* Facade layer providing domain-grouped APIs for control plane operations.
|
||||
* Shares controller instances with ControlDispatcher to ensure single source
|
||||
* of truth and state consistency.
|
||||
*/
|
||||
export class ControlService {
|
||||
private dispatcher: ControlDispatcher;
|
||||
|
||||
/**
|
||||
* Construct ControlService
|
||||
*
|
||||
* @param context - Control context (unused directly, passed to dispatcher)
|
||||
* @param dispatcher - Control dispatcher that owns the controller instances
|
||||
*/
|
||||
constructor(context: IControlContext, dispatcher: ControlDispatcher) {
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission Domain API
|
||||
*
|
||||
* Handles tool execution permissions, approval checks, and callbacks.
|
||||
* Delegates to the shared PermissionController instance.
|
||||
*/
|
||||
// get permission(): PermissionServiceAPI {
|
||||
// const controller = this.dispatcher.permissionController;
|
||||
// return {
|
||||
// /**
|
||||
// * Check if a tool should be allowed based on current permission settings
|
||||
// *
|
||||
// * Evaluates permission mode and tool registry to determine if execution
|
||||
// * should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
// *
|
||||
// * @param toolRequest - Tool call request information
|
||||
// * @param confirmationDetails - Optional confirmation details for UI
|
||||
// * @returns Permission decision with optional updated arguments
|
||||
// */
|
||||
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Build UI suggestions for tool confirmation dialogs
|
||||
// *
|
||||
// * Creates actionable permission suggestions based on tool confirmation details.
|
||||
// *
|
||||
// * @param confirmationDetails - Tool confirmation details
|
||||
// * @returns Array of permission suggestions or null
|
||||
// */
|
||||
// buildPermissionSuggestions:
|
||||
// controller.buildPermissionSuggestions.bind(controller),
|
||||
//
|
||||
// /**
|
||||
// * Get callback for monitoring tool call status updates
|
||||
// *
|
||||
// * Returns callback function for integration with CoreToolScheduler.
|
||||
// *
|
||||
// * @returns Callback function for tool call updates
|
||||
// */
|
||||
// getToolCallUpdateCallback:
|
||||
// controller.getToolCallUpdateCallback.bind(controller),
|
||||
// };
|
||||
// }
|
||||
|
||||
/**
|
||||
* System Domain API
|
||||
*
|
||||
* Handles system-level operations and session management.
|
||||
* Delegates to the shared SystemController instance.
|
||||
*/
|
||||
get system(): SystemServiceAPI {
|
||||
const controller = this.dispatcher.systemController;
|
||||
return {
|
||||
/**
|
||||
* Get control capabilities
|
||||
*
|
||||
* Returns the control capabilities object indicating what control
|
||||
* features are available. Used exclusively for the initialize
|
||||
* control response. System messages do not include capabilities.
|
||||
*
|
||||
* @returns Control capabilities object
|
||||
*/
|
||||
getControlCapabilities: () => controller.buildControlCapabilities(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Domain API
|
||||
*
|
||||
* Handles Model Context Protocol server interactions.
|
||||
* Delegates to the shared MCPController instance.
|
||||
*/
|
||||
// get mcp(): McpServiceAPI {
|
||||
// return {
|
||||
// /**
|
||||
// * Get or create MCP client for a server (lazy initialization)
|
||||
// *
|
||||
// * Returns existing client or creates new connection.
|
||||
// *
|
||||
// * @param serverName - Name of the MCP server
|
||||
// * @returns Promise with client and config
|
||||
// */
|
||||
// getMcpClient: async (serverName: string) => {
|
||||
// // MCPController has a private method getOrCreateMcpClient
|
||||
// // We need to expose it via the API
|
||||
// // For now, throw error as placeholder
|
||||
// // The actual implementation will be added when we update MCPController
|
||||
// throw new Error(
|
||||
// `getMcpClient not yet implemented in ControlService. Server: ${serverName}`,
|
||||
// );
|
||||
// },
|
||||
//
|
||||
// /**
|
||||
// * List all available MCP servers
|
||||
// *
|
||||
// * Returns names of configured/connected MCP servers.
|
||||
// *
|
||||
// * @returns Array of server names
|
||||
// */
|
||||
// listServers: () => {
|
||||
// // Get servers from context
|
||||
// const sdkServers = Array.from(
|
||||
// this.dispatcher.mcpController['context'].sdkMcpServers,
|
||||
// );
|
||||
// const cliServers = Array.from(
|
||||
// this.dispatcher.mcpController['context'].mcpClients.keys(),
|
||||
// );
|
||||
// return [...new Set([...sdkServers, ...cliServers])];
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
/**
|
||||
* Hook Domain API
|
||||
*
|
||||
* Handles hook callback processing (placeholder for future expansion).
|
||||
* Delegates to the shared HookController instance.
|
||||
*/
|
||||
// get hook(): HookServiceAPI {
|
||||
// // HookController has no public methods yet - controller access reserved for future use
|
||||
// return {};
|
||||
// }
|
||||
|
||||
/**
|
||||
* Cleanup all controllers
|
||||
*
|
||||
* Should be called on session shutdown. Delegates to dispatcher's shutdown
|
||||
* method to ensure all controllers are properly cleaned up.
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Delegate to dispatcher which manages controller cleanup
|
||||
this.dispatcher.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base Controller
|
||||
*
|
||||
* Abstract base class for domain-specific control plane controllers.
|
||||
* Provides common functionality for:
|
||||
* - Handling incoming control requests (SDK -> CLI)
|
||||
* - Sending outgoing control requests (CLI -> SDK)
|
||||
* - Request lifecycle management with timeout and cancellation
|
||||
* - Integration with central pending request registry
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IControlContext } from '../ControlContext.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
ControlResponse,
|
||||
CLIControlRequest,
|
||||
} from '../../types.js';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Registry interface for controllers to register/deregister pending requests
|
||||
*/
|
||||
export interface IPendingRequestRegistry {
|
||||
registerIncomingRequest(
|
||||
requestId: string,
|
||||
controller: string,
|
||||
abortController: AbortController,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
): void;
|
||||
deregisterIncomingRequest(requestId: string): void;
|
||||
|
||||
registerOutgoingRequest(
|
||||
requestId: string,
|
||||
controller: string,
|
||||
resolve: (response: ControlResponse) => void,
|
||||
reject: (error: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
): void;
|
||||
deregisterOutgoingRequest(requestId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base controller class
|
||||
*
|
||||
* Subclasses should implement handleRequestPayload() to process specific
|
||||
* control request types.
|
||||
*/
|
||||
export abstract class BaseController {
|
||||
protected context: IControlContext;
|
||||
protected registry: IPendingRequestRegistry;
|
||||
protected controllerName: string;
|
||||
|
||||
constructor(
|
||||
context: IControlContext,
|
||||
registry: IPendingRequestRegistry,
|
||||
controllerName: string,
|
||||
) {
|
||||
this.context = context;
|
||||
this.registry = registry;
|
||||
this.controllerName = controllerName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming control request
|
||||
*
|
||||
* Manages lifecycle: register -> process -> deregister
|
||||
*/
|
||||
async handleRequest(
|
||||
payload: ControlRequestPayload,
|
||||
requestId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const requestAbortController = new AbortController();
|
||||
|
||||
// Setup timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
requestAbortController.abort();
|
||||
this.registry.deregisterIncomingRequest(requestId);
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[${this.controllerName}] Request timeout: ${requestId}`);
|
||||
}
|
||||
}, DEFAULT_REQUEST_TIMEOUT_MS);
|
||||
|
||||
// Register with central registry
|
||||
this.registry.registerIncomingRequest(
|
||||
requestId,
|
||||
this.controllerName,
|
||||
requestAbortController,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await this.handleRequestPayload(
|
||||
payload,
|
||||
requestAbortController.signal,
|
||||
);
|
||||
|
||||
// Success - deregister
|
||||
this.registry.deregisterIncomingRequest(requestId);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Error - deregister
|
||||
this.registry.deregisterIncomingRequest(requestId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an outgoing control request to SDK
|
||||
*
|
||||
* Manages lifecycle: register -> send -> wait for response -> deregister
|
||||
*/
|
||||
async sendControlRequest(
|
||||
payload: ControlRequestPayload,
|
||||
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
): Promise<ControlResponse> {
|
||||
const requestId = randomUUID();
|
||||
|
||||
return new Promise<ControlResponse>((resolve, reject) => {
|
||||
// Setup timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(new Error('Control request timeout'));
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[${this.controllerName}] Outgoing request timeout: ${requestId}`,
|
||||
);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
// Register with central registry
|
||||
this.registry.registerOutgoingRequest(
|
||||
requestId,
|
||||
this.controllerName,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
// Send control request
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: requestId,
|
||||
request: payload,
|
||||
};
|
||||
|
||||
try {
|
||||
this.context.streamJson.send(request);
|
||||
} catch (error) {
|
||||
this.registry.deregisterOutgoingRequest(requestId);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract method: Handle specific request payload
|
||||
*
|
||||
* Subclasses must implement this to process their domain-specific requests.
|
||||
*/
|
||||
protected abstract handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Subclasses can override to add cleanup logic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hook Controller
|
||||
*
|
||||
* Handles hook-related control requests:
|
||||
* - hook_callback: Process hook callbacks (placeholder for future)
|
||||
*/
|
||||
|
||||
import { BaseController } from './baseController.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIHookCallbackRequest,
|
||||
} from '../../types.js';
|
||||
|
||||
export class HookController extends BaseController {
|
||||
/**
|
||||
* Handle hook control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'hook_callback':
|
||||
return this.handleHookCallback(payload as CLIHookCallbackRequest);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in HookController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hook_callback request
|
||||
*
|
||||
* Processes hook callbacks (placeholder implementation)
|
||||
*/
|
||||
private async handleHookCallback(
|
||||
payload: CLIHookCallbackRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[HookController] Hook callback: ${payload.callback_id}`);
|
||||
}
|
||||
|
||||
// Hook callback processing not yet implemented
|
||||
return {
|
||||
result: 'Hook callback processing not yet implemented',
|
||||
callback_id: payload.callback_id,
|
||||
tool_use_id: payload.tool_use_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP Controller
|
||||
*
|
||||
* Handles MCP-related control requests:
|
||||
* - mcp_message: Route MCP messages
|
||||
* - mcp_server_status: Return MCP server status
|
||||
*/
|
||||
|
||||
import { BaseController } from './baseController.js';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlMcpMessageRequest,
|
||||
} from '../../types.js';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
WorkspaceContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
connectToMcpServer,
|
||||
MCP_DEFAULT_TIMEOUT_MSEC,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class MCPController extends BaseController {
|
||||
/**
|
||||
* Handle MCP control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'mcp_message':
|
||||
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
|
||||
|
||||
case 'mcp_server_status':
|
||||
return this.handleMcpStatus();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in MCPController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_message request
|
||||
*
|
||||
* Routes JSON-RPC messages to MCP servers
|
||||
*/
|
||||
private async handleMcpMessage(
|
||||
payload: CLIControlMcpMessageRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const serverNameRaw = payload.server_name;
|
||||
if (
|
||||
typeof serverNameRaw !== 'string' ||
|
||||
serverNameRaw.trim().length === 0
|
||||
) {
|
||||
throw new Error('Missing server_name in mcp_message request');
|
||||
}
|
||||
|
||||
const message = payload.message;
|
||||
if (!message || typeof message !== 'object') {
|
||||
throw new Error(
|
||||
'Missing or invalid message payload for mcp_message request',
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create MCP client
|
||||
let clientEntry: { client: Client; config: MCPServerConfig };
|
||||
try {
|
||||
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to connect to MCP server',
|
||||
);
|
||||
}
|
||||
|
||||
const method = message.method;
|
||||
if (typeof method !== 'string' || method.trim().length === 0) {
|
||||
throw new Error('Invalid MCP message: missing method');
|
||||
}
|
||||
|
||||
const jsonrpcVersion =
|
||||
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
|
||||
const messageId = message.id;
|
||||
const params = message.params;
|
||||
const timeout =
|
||||
typeof clientEntry.config.timeout === 'number'
|
||||
? clientEntry.config.timeout
|
||||
: MCP_DEFAULT_TIMEOUT_MSEC;
|
||||
|
||||
try {
|
||||
// Handle notification (no id)
|
||||
if (messageId === undefined) {
|
||||
await clientEntry.client.notification({
|
||||
method,
|
||||
params,
|
||||
});
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: null,
|
||||
result: { success: true, acknowledged: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle request (with id)
|
||||
const result = await clientEntry.client.request(
|
||||
{
|
||||
method,
|
||||
params,
|
||||
},
|
||||
ResultSchema,
|
||||
{ timeout },
|
||||
);
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId,
|
||||
result,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If connection closed, remove from cache
|
||||
if (error instanceof Error && /closed/i.test(error.message)) {
|
||||
this.context.mcpClients.delete(serverNameRaw.trim());
|
||||
}
|
||||
|
||||
const errorCode =
|
||||
typeof (error as { code?: unknown })?.code === 'number'
|
||||
? ((error as { code: number }).code as number)
|
||||
: -32603;
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to execute MCP request';
|
||||
const errorData = (error as { data?: unknown })?.data;
|
||||
|
||||
const errorBody: Record<string, unknown> = {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
};
|
||||
if (errorData !== undefined) {
|
||||
errorBody['data'] = errorData;
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'mcp_message',
|
||||
mcp_response: {
|
||||
jsonrpc: jsonrpcVersion,
|
||||
id: messageId ?? null,
|
||||
error: errorBody,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mcp_server_status request
|
||||
*
|
||||
* Returns status of registered MCP servers
|
||||
*/
|
||||
private async handleMcpStatus(): Promise<Record<string, unknown>> {
|
||||
const status: Record<string, string> = {};
|
||||
|
||||
// Include SDK MCP servers
|
||||
for (const serverName of this.context.sdkMcpServers) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
// Include CLI-managed MCP clients
|
||||
for (const serverName of this.context.mcpClients.keys()) {
|
||||
status[serverName] = 'connected';
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
|
||||
);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create MCP client for a server
|
||||
*
|
||||
* Implements lazy connection and caching
|
||||
*/
|
||||
private async getOrCreateMcpClient(
|
||||
serverName: string,
|
||||
): Promise<{ client: Client; config: MCPServerConfig }> {
|
||||
// Check cache first
|
||||
const cached = this.context.mcpClients.get(serverName);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get server configuration
|
||||
const provider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
|
||||
getDebugMode?: () => boolean;
|
||||
getWorkspaceContext?: () => unknown;
|
||||
};
|
||||
|
||||
if (typeof provider.getMcpServers !== 'function') {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const servers = provider.getMcpServers() ?? {};
|
||||
const serverConfig = servers[serverName];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`MCP server "${serverName}" is not configured`);
|
||||
}
|
||||
|
||||
const debugMode =
|
||||
typeof provider.getDebugMode === 'function'
|
||||
? provider.getDebugMode()
|
||||
: false;
|
||||
|
||||
const workspaceContext =
|
||||
typeof provider.getWorkspaceContext === 'function'
|
||||
? provider.getWorkspaceContext()
|
||||
: undefined;
|
||||
|
||||
if (!workspaceContext) {
|
||||
throw new Error('Workspace context is not available for MCP connection');
|
||||
}
|
||||
|
||||
// Connect to MCP server
|
||||
const client = await connectToMcpServer(
|
||||
serverName,
|
||||
serverConfig,
|
||||
debugMode,
|
||||
workspaceContext as WorkspaceContext,
|
||||
);
|
||||
|
||||
// Cache the client
|
||||
const entry = { client, config: serverConfig };
|
||||
this.context.mcpClients.set(serverName, entry);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup MCP clients
|
||||
*/
|
||||
override cleanup(): void {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
|
||||
);
|
||||
}
|
||||
|
||||
// Close all MCP clients
|
||||
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[MCPController] Failed to close MCP client ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.context.mcpClients.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Permission Controller
|
||||
*
|
||||
* Handles permission-related control requests:
|
||||
* - can_use_tool: Check if tool usage is allowed
|
||||
* - set_permission_mode: Change permission mode at runtime
|
||||
*
|
||||
* Abstracts all permission logic from the session manager to keep it clean.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
WaitingToolCall,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
InputFormat,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CLIControlPermissionRequest,
|
||||
CLIControlSetPermissionModeRequest,
|
||||
ControlRequestPayload,
|
||||
PermissionMode,
|
||||
PermissionSuggestion,
|
||||
} from '../../types.js';
|
||||
import { BaseController } from './baseController.js';
|
||||
|
||||
// Import ToolCallConfirmationDetails types for type alignment
|
||||
type ToolConfirmationType = 'edit' | 'exec' | 'mcp' | 'info' | 'plan';
|
||||
|
||||
export class PermissionController extends BaseController {
|
||||
private pendingOutgoingRequests = new Set<string>();
|
||||
|
||||
/**
|
||||
* Handle permission control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'can_use_tool':
|
||||
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
|
||||
|
||||
case 'set_permission_mode':
|
||||
return this.handleSetPermissionMode(
|
||||
payload as CLIControlSetPermissionModeRequest,
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in PermissionController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle can_use_tool request
|
||||
*
|
||||
* Comprehensive permission evaluation based on:
|
||||
* - Permission mode (approval level)
|
||||
* - Tool registry validation
|
||||
* - Error handling with safe defaults
|
||||
*/
|
||||
private async handleCanUseTool(
|
||||
payload: CLIControlPermissionRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const toolName = payload.tool_name;
|
||||
if (
|
||||
!toolName ||
|
||||
typeof toolName !== 'string' ||
|
||||
toolName.trim().length === 0
|
||||
) {
|
||||
return {
|
||||
subtype: 'can_use_tool',
|
||||
behavior: 'deny',
|
||||
message: 'Missing or invalid tool_name in can_use_tool request',
|
||||
};
|
||||
}
|
||||
|
||||
let behavior: 'allow' | 'deny' = 'allow';
|
||||
let message: string | undefined;
|
||||
|
||||
try {
|
||||
// Check permission mode first
|
||||
const permissionResult = this.checkPermissionMode();
|
||||
if (!permissionResult.allowed) {
|
||||
behavior = 'deny';
|
||||
message = permissionResult.message;
|
||||
}
|
||||
|
||||
// Check tool registry if permission mode allows
|
||||
if (behavior === 'allow') {
|
||||
const registryResult = this.checkToolRegistry(toolName);
|
||||
if (!registryResult.allowed) {
|
||||
behavior = 'deny';
|
||||
message = registryResult.message;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
behavior = 'deny';
|
||||
message =
|
||||
error instanceof Error
|
||||
? `Failed to evaluate tool permission: ${error.message}`
|
||||
: 'Failed to evaluate tool permission';
|
||||
}
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
subtype: 'can_use_tool',
|
||||
behavior,
|
||||
};
|
||||
|
||||
if (message) {
|
||||
response['message'] = message;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permission mode for tool execution
|
||||
*/
|
||||
private checkPermissionMode(): { allowed: boolean; message?: string } {
|
||||
const mode = this.context.permissionMode;
|
||||
|
||||
// Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES)
|
||||
switch (mode) {
|
||||
case 'yolo': // Allow all tools
|
||||
case 'auto-edit': // Auto-approve edit operations
|
||||
case 'plan': // Auto-approve planning operations
|
||||
return { allowed: true };
|
||||
|
||||
case 'default': // TODO: allow all tools for test
|
||||
default:
|
||||
return {
|
||||
allowed: false,
|
||||
message:
|
||||
'Tool execution requires manual approval. Update permission mode or approve via host.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool exists in registry
|
||||
*/
|
||||
private checkToolRegistry(toolName: string): {
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
} {
|
||||
try {
|
||||
// Access tool registry through config
|
||||
const config = this.context.config;
|
||||
const registryProvider = config as unknown as {
|
||||
getToolRegistry?: () => {
|
||||
getTool?: (name: string) => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
if (typeof registryProvider.getToolRegistry === 'function') {
|
||||
const registry = registryProvider.getToolRegistry();
|
||||
if (
|
||||
registry &&
|
||||
typeof registry.getTool === 'function' &&
|
||||
!registry.getTool(toolName)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: `Tool "${toolName}" is not registered.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: `Failed to check tool registry: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set_permission_mode request
|
||||
*
|
||||
* Updates the permission mode in the context
|
||||
*/
|
||||
private async handleSetPermissionMode(
|
||||
payload: CLIControlSetPermissionModeRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const mode = payload.mode;
|
||||
const validModes: PermissionMode[] = [
|
||||
'default',
|
||||
'plan',
|
||||
'auto-edit',
|
||||
'yolo',
|
||||
];
|
||||
|
||||
if (!validModes.includes(mode)) {
|
||||
throw new Error(
|
||||
`Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.context.permissionMode = mode;
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[PermissionController] Permission mode updated to: ${mode}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { status: 'updated', mode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build permission suggestions for tool confirmation UI
|
||||
*
|
||||
* This method creates UI suggestions based on tool confirmation details,
|
||||
* helping the host application present appropriate permission options.
|
||||
*/
|
||||
buildPermissionSuggestions(
|
||||
confirmationDetails: unknown,
|
||||
): PermissionSuggestion[] | null {
|
||||
if (
|
||||
!confirmationDetails ||
|
||||
typeof confirmationDetails !== 'object' ||
|
||||
!('type' in confirmationDetails)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const details = confirmationDetails as Record<string, unknown>;
|
||||
const type = String(details['type'] ?? '');
|
||||
const title =
|
||||
typeof details['title'] === 'string' ? details['title'] : undefined;
|
||||
|
||||
// Ensure type matches ToolCallConfirmationDetails union
|
||||
const confirmationType = type as ToolConfirmationType;
|
||||
|
||||
switch (confirmationType) {
|
||||
case 'exec': // ToolExecuteConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow Command',
|
||||
description: `Execute: ${details['command']}`,
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: 'Block this command execution',
|
||||
},
|
||||
];
|
||||
|
||||
case 'edit': // ToolEditConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow Edit',
|
||||
description: `Edit file: ${details['fileName']}`,
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: 'Block this file edit',
|
||||
},
|
||||
{
|
||||
type: 'modify',
|
||||
label: 'Review Changes',
|
||||
description: 'Review the proposed changes before applying',
|
||||
},
|
||||
];
|
||||
|
||||
case 'plan': // ToolPlanConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Approve Plan',
|
||||
description: title || 'Execute the proposed plan',
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Reject Plan',
|
||||
description: 'Do not execute this plan',
|
||||
},
|
||||
];
|
||||
|
||||
case 'mcp': // ToolMcpConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow MCP Call',
|
||||
description: `${details['serverName']}: ${details['toolName']}`,
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: 'Block this MCP server call',
|
||||
},
|
||||
];
|
||||
|
||||
case 'info': // ToolInfoConfirmationDetails
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow Info Request',
|
||||
description: title || 'Allow information request',
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: 'Block this information request',
|
||||
},
|
||||
];
|
||||
|
||||
default:
|
||||
// Fallback for unknown types
|
||||
return [
|
||||
{
|
||||
type: 'allow',
|
||||
label: 'Allow',
|
||||
description: title || `Allow ${type} operation`,
|
||||
},
|
||||
{
|
||||
type: 'deny',
|
||||
label: 'Deny',
|
||||
description: `Block ${type} operation`,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool should be executed based on current permission settings
|
||||
*
|
||||
* This is a convenience method for direct tool execution checks without
|
||||
* going through the control request flow.
|
||||
*/
|
||||
async shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}> {
|
||||
// Check permission mode
|
||||
const modeResult = this.checkPermissionMode();
|
||||
if (!modeResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: modeResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// Check tool registry
|
||||
const registryResult = this.checkToolRegistry(toolRequest.name);
|
||||
if (!registryResult.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
message: registryResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have confirmation details, we could potentially modify args
|
||||
// This is a hook for future enhancement
|
||||
if (confirmationDetails) {
|
||||
// Future: handle argument modifications based on confirmation details
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool calls and handling outgoing permission requests
|
||||
* This is passed to executeToolCall to hook into CoreToolScheduler updates
|
||||
*/
|
||||
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void {
|
||||
return (toolCalls: unknown[]) => {
|
||||
for (const call of toolCalls) {
|
||||
if (
|
||||
call &&
|
||||
typeof call === 'object' &&
|
||||
(call as { status?: string }).status === 'awaiting_approval'
|
||||
) {
|
||||
const awaiting = call as WaitingToolCall;
|
||||
if (
|
||||
typeof awaiting.confirmationDetails?.onConfirm === 'function' &&
|
||||
!this.pendingOutgoingRequests.has(awaiting.request.callId)
|
||||
) {
|
||||
this.pendingOutgoingRequests.add(awaiting.request.callId);
|
||||
void this.handleOutgoingPermissionRequest(awaiting);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle outgoing permission request
|
||||
*
|
||||
* Behavior depends on input format:
|
||||
* - stream-json mode: Send can_use_tool to SDK and await response
|
||||
* - Other modes: Check local approval mode and decide immediately
|
||||
*/
|
||||
private async handleOutgoingPermissionRequest(
|
||||
toolCall: WaitingToolCall,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const inputFormat = this.context.config.getInputFormat?.();
|
||||
const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON;
|
||||
|
||||
if (!isStreamJsonMode) {
|
||||
// No SDK available - use local permission check
|
||||
const modeCheck = this.checkPermissionMode();
|
||||
const outcome = modeCheck.allowed
|
||||
? ToolConfirmationOutcome.ProceedOnce
|
||||
: ToolConfirmationOutcome.Cancel;
|
||||
|
||||
await toolCall.confirmationDetails.onConfirm(outcome);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream-json mode: ask SDK for permission
|
||||
const permissionSuggestions = this.buildPermissionSuggestions(
|
||||
toolCall.confirmationDetails,
|
||||
);
|
||||
|
||||
const response = await this.sendControlRequest(
|
||||
{
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: toolCall.request.name,
|
||||
tool_use_id: toolCall.request.callId,
|
||||
input: toolCall.request.args,
|
||||
permission_suggestions: permissionSuggestions,
|
||||
blocked_path: null,
|
||||
} as CLIControlPermissionRequest,
|
||||
30000,
|
||||
);
|
||||
|
||||
if (response.subtype !== 'success') {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (response.response || {}) as Record<string, unknown>;
|
||||
const behavior = String(payload['behavior'] || '').toLowerCase();
|
||||
|
||||
if (behavior === 'allow') {
|
||||
// Handle updated input if provided
|
||||
const updatedInput = payload['updatedInput'];
|
||||
if (updatedInput && typeof updatedInput === 'object') {
|
||||
toolCall.request.args = updatedInput as Record<string, unknown>;
|
||||
}
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
} else {
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[PermissionController] Outgoing permission failed:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
);
|
||||
} finally {
|
||||
this.pendingOutgoingRequests.delete(toolCall.request.callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* System Controller
|
||||
*
|
||||
* Handles system-level control requests:
|
||||
* - initialize: Setup session and return system info
|
||||
* - interrupt: Cancel current operations
|
||||
* - set_model: Switch model (placeholder)
|
||||
*/
|
||||
|
||||
import { BaseController } from './baseController.js';
|
||||
import type {
|
||||
ControlRequestPayload,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlSetModelRequest,
|
||||
} from '../../types.js';
|
||||
|
||||
export class SystemController extends BaseController {
|
||||
/**
|
||||
* Handle system control requests
|
||||
*/
|
||||
protected async handleRequestPayload(
|
||||
payload: ControlRequestPayload,
|
||||
_signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
switch (payload.subtype) {
|
||||
case 'initialize':
|
||||
return this.handleInitialize(payload as CLIControlInitializeRequest);
|
||||
|
||||
case 'interrupt':
|
||||
return this.handleInterrupt();
|
||||
|
||||
case 'set_model':
|
||||
return this.handleSetModel(payload as CLIControlSetModelRequest);
|
||||
|
||||
case 'supported_commands':
|
||||
return this.handleSupportedCommands();
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported request subtype in SystemController`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle initialize request
|
||||
*
|
||||
* Registers SDK MCP servers and returns capabilities
|
||||
*/
|
||||
private async handleInitialize(
|
||||
payload: CLIControlInitializeRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Register SDK MCP servers if provided
|
||||
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
|
||||
for (const serverName of payload.sdkMcpServers) {
|
||||
this.context.sdkMcpServers.add(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
// Build capabilities for response
|
||||
const capabilities = this.buildControlCapabilities();
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'initialize',
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build control capabilities for initialize control response
|
||||
*
|
||||
* This method constructs the control capabilities object that indicates
|
||||
* what control features are available. It is used exclusively in the
|
||||
* initialize control response.
|
||||
*/
|
||||
buildControlCapabilities(): Record<string, unknown> {
|
||||
const capabilities: Record<string, unknown> = {
|
||||
can_handle_can_use_tool: true,
|
||||
can_handle_hook_callback: true,
|
||||
can_set_permission_mode:
|
||||
typeof this.context.config.setApprovalMode === 'function',
|
||||
can_set_model: typeof this.context.config.setModel === 'function',
|
||||
};
|
||||
|
||||
// Check if MCP message handling is available
|
||||
try {
|
||||
const mcpProvider = this.context.config as unknown as {
|
||||
getMcpServers?: () => Record<string, unknown> | undefined;
|
||||
};
|
||||
if (typeof mcpProvider.getMcpServers === 'function') {
|
||||
const servers = mcpProvider.getMcpServers();
|
||||
capabilities['can_handle_mcp_message'] = Boolean(
|
||||
servers && Object.keys(servers).length > 0,
|
||||
);
|
||||
} else {
|
||||
capabilities['can_handle_mcp_message'] = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
'[SystemController] Failed to determine MCP capability:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
capabilities['can_handle_mcp_message'] = false;
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interrupt request
|
||||
*
|
||||
* Triggers the interrupt callback to cancel current operations
|
||||
*/
|
||||
private async handleInterrupt(): Promise<Record<string, unknown>> {
|
||||
// Trigger interrupt callback if available
|
||||
if (this.context.onInterrupt) {
|
||||
this.context.onInterrupt();
|
||||
}
|
||||
|
||||
// Abort the main signal to cancel ongoing operations
|
||||
if (this.context.abortSignal && !this.context.abortSignal.aborted) {
|
||||
// Note: We can't directly abort the signal, but the onInterrupt callback should handle this
|
||||
if (this.context.debugMode) {
|
||||
console.error('[SystemController] Interrupt signal triggered');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error('[SystemController] Interrupt handled');
|
||||
}
|
||||
|
||||
return { subtype: 'interrupt' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set_model request
|
||||
*
|
||||
* Implements actual model switching with validation and error handling
|
||||
*/
|
||||
private async handleSetModel(
|
||||
payload: CLIControlSetModelRequest,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const model = payload.model;
|
||||
|
||||
// Validate model parameter
|
||||
if (typeof model !== 'string' || model.trim() === '') {
|
||||
throw new Error('Invalid model specified for set_model request');
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to set the model using config
|
||||
await this.context.config.setModel(model);
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(`[SystemController] Model switched to: ${model}`);
|
||||
}
|
||||
|
||||
return {
|
||||
subtype: 'set_model',
|
||||
model,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to set model';
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[SystemController] Failed to set model ${model}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle supported_commands request
|
||||
*
|
||||
* Returns list of supported control commands
|
||||
*
|
||||
* Note: This list should match the ControlRequestType enum in
|
||||
* packages/sdk/typescript/src/types/controlRequests.ts
|
||||
*/
|
||||
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
|
||||
const commands = [
|
||||
'initialize',
|
||||
'interrupt',
|
||||
'set_model',
|
||||
'supported_commands',
|
||||
'can_use_tool',
|
||||
'set_permission_mode',
|
||||
'mcp_message',
|
||||
'mcp_server_status',
|
||||
'hook_callback',
|
||||
];
|
||||
|
||||
return {
|
||||
subtype: 'supported_commands',
|
||||
commands,
|
||||
};
|
||||
}
|
||||
}
|
||||
139
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts
Normal file
139
packages/cli/src/nonInteractive/control/types/serviceAPIs.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Service API Types
|
||||
*
|
||||
* These interfaces define the public API contract for the ControlService facade.
|
||||
* They provide type-safe, domain-grouped access to control plane functionality
|
||||
* for internal CLI code (nonInteractiveCli, session managers, etc.).
|
||||
*/
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { PermissionSuggestion } from '../../types.js';
|
||||
|
||||
/**
|
||||
* Permission Service API
|
||||
*
|
||||
* Provides permission-related operations including tool execution approval,
|
||||
* permission suggestions, and tool call monitoring callbacks.
|
||||
*/
|
||||
export interface PermissionServiceAPI {
|
||||
/**
|
||||
* Check if a tool should be allowed based on current permission settings
|
||||
*
|
||||
* Evaluates permission mode and tool registry to determine if execution
|
||||
* should proceed. Can optionally modify tool arguments based on confirmation details.
|
||||
*
|
||||
* @param toolRequest - Tool call request information containing name, args, and call ID
|
||||
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
|
||||
* @returns Promise resolving to permission decision with optional updated arguments
|
||||
*/
|
||||
shouldAllowTool(
|
||||
toolRequest: ToolCallRequestInfo,
|
||||
confirmationDetails?: unknown,
|
||||
): Promise<{
|
||||
allowed: boolean;
|
||||
message?: string;
|
||||
updatedArgs?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Build UI suggestions for tool confirmation dialogs
|
||||
*
|
||||
* Creates actionable permission suggestions based on tool confirmation details,
|
||||
* helping host applications present appropriate approval/denial options.
|
||||
*
|
||||
* @param confirmationDetails - Tool confirmation details (type, title, metadata)
|
||||
* @returns Array of permission suggestions or null if details are invalid
|
||||
*/
|
||||
buildPermissionSuggestions(
|
||||
confirmationDetails: unknown,
|
||||
): PermissionSuggestion[] | null;
|
||||
|
||||
/**
|
||||
* Get callback for monitoring tool call status updates
|
||||
*
|
||||
* Returns a callback function that should be passed to executeToolCall
|
||||
* to enable integration with CoreToolScheduler updates. This callback
|
||||
* handles outgoing permission requests for tools awaiting approval.
|
||||
*
|
||||
* @returns Callback function that processes tool call updates
|
||||
*/
|
||||
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* System Service API
|
||||
*
|
||||
* Provides system-level operations for the control system.
|
||||
*
|
||||
* Note: System messages and slash commands are NOT part of the control system API.
|
||||
* They are handled independently via buildSystemMessage() from nonInteractiveHelpers.ts,
|
||||
* regardless of whether the control system is available.
|
||||
*/
|
||||
export interface SystemServiceAPI {
|
||||
/**
|
||||
* Get control capabilities
|
||||
*
|
||||
* Returns the control capabilities object indicating what control
|
||||
* features are available. Used exclusively for the initialize control
|
||||
* response. System messages do not include capabilities as they are
|
||||
* independent of the control system.
|
||||
*
|
||||
* @returns Control capabilities object
|
||||
*/
|
||||
getControlCapabilities(): Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Service API
|
||||
*
|
||||
* Provides Model Context Protocol server interaction including
|
||||
* lazy client initialization and server discovery.
|
||||
*/
|
||||
export interface McpServiceAPI {
|
||||
/**
|
||||
* Get or create MCP client for a server (lazy initialization)
|
||||
*
|
||||
* Returns an existing client from cache or creates a new connection
|
||||
* if this is the first request for the server. Handles connection
|
||||
* lifecycle and error recovery.
|
||||
*
|
||||
* @param serverName - Name of the MCP server to connect to
|
||||
* @returns Promise resolving to client instance and server configuration
|
||||
* @throws Error if server is not configured or connection fails
|
||||
*/
|
||||
getMcpClient(serverName: string): Promise<{
|
||||
client: Client;
|
||||
config: MCPServerConfig;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* List all available MCP servers
|
||||
*
|
||||
* Returns names of both SDK-managed and CLI-managed MCP servers
|
||||
* that are currently configured or connected.
|
||||
*
|
||||
* @returns Array of server names
|
||||
*/
|
||||
listServers(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook Service API
|
||||
*
|
||||
* Provides hook callback processing (placeholder for future expansion).
|
||||
*/
|
||||
export interface HookServiceAPI {
|
||||
// Future: Hook-related methods will be added here
|
||||
// For now, hook functionality is handled only via control requests
|
||||
registerHookCallback(callback: unknown): void;
|
||||
}
|
||||
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
@@ -0,0 +1,791 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
Config,
|
||||
ServerGeminiStreamEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { JsonOutputAdapter } from './JsonOutputAdapter.js';
|
||||
|
||||
function createMockConfig(): Config {
|
||||
return {
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('JsonOutputAdapter', () => {
|
||||
let adapter: JsonOutputAdapter;
|
||||
let mockConfig: Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let stdoutWriteSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = createMockConfig();
|
||||
adapter = new JsonOutputAdapter(mockConfig);
|
||||
stdoutWriteSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutWriteSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('startAssistantMessage', () => {
|
||||
it('should reset state for new message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.startAssistantMessage(); // Start second message
|
||||
// Should not throw
|
||||
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should append text content from Content events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const event2: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: ' World',
|
||||
};
|
||||
adapter.processEvent(event2);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append citation content from Citation events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Citation,
|
||||
value: 'Citation text',
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('Citation text'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-string citation values', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Citation,
|
||||
value: 123,
|
||||
} as unknown as ServerGeminiStreamEvent;
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should append thinking from Thought events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
thinking: 'Planning: Thinking about the task',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinking with only subject', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: '',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append tool use from ToolCallRequest events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
id: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
input: { param1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains text blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains thinking blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool_1',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-2',
|
||||
name: 'test_tool_2',
|
||||
args: { param2: 'value2' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(2);
|
||||
expect(
|
||||
message.message.content.every((block) => block.type === 'tool_use'),
|
||||
).toBe(true);
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should update usage from Finished event', () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 10,
|
||||
totalTokenCount: 160,
|
||||
};
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: undefined,
|
||||
usageMetadata,
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.usage).toMatchObject({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 10,
|
||||
total_tokens: 160,
|
||||
});
|
||||
});
|
||||
|
||||
it('should finalize pending blocks on Finished event', () => {
|
||||
// Add some text first
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: undefined },
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
// Should not throw when finalizing
|
||||
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should ignore events after finalization', () => {
|
||||
adapter.finalizeAssistantMessage();
|
||||
const originalContent =
|
||||
adapter.finalizeAssistantMessage().message.content;
|
||||
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Should be ignored',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toEqual(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeAssistantMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should build and emit a complete assistant message', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test response',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message.type).toBe('assistant');
|
||||
expect(message.uuid).toBeTruthy();
|
||||
expect(message.session_id).toBe('test-session-id');
|
||||
expect(message.parent_tool_use_id).toBeNull();
|
||||
expect(message.message.role).toBe('assistant');
|
||||
expect(message.message.model).toBe('test-model');
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return same message on subsequent calls', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message1 = adapter.finalizeAssistantMessage();
|
||||
const message2 = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message1).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should split different block types into separate assistant messages', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0].type).toBe('thinking');
|
||||
|
||||
const storedMessages = (adapter as unknown as { messages: unknown[] })
|
||||
.messages;
|
||||
const assistantMessages = storedMessages.filter(
|
||||
(
|
||||
msg,
|
||||
): msg is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof msg !== 'object' ||
|
||||
msg === null ||
|
||||
!('type' in msg) ||
|
||||
(msg as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in msg)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (msg as { message?: unknown }).message;
|
||||
return (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'content' in message &&
|
||||
Array.isArray((message as { content?: unknown }).content)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
for (const assistant of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
assistant.message.content.map((block) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw if message not started', () => {
|
||||
adapter = new JsonOutputAdapter(mockConfig);
|
||||
expect(() => adapter.finalizeAssistantMessage()).toThrow(
|
||||
'Message not started',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Response text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit success result as JSON array', () => {
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage.subtype).toBe('success');
|
||||
expect(resultMessage.result).toBe('Response text');
|
||||
expect(resultMessage.duration_ms).toBe(1000);
|
||||
expect(resultMessage.num_turns).toBe(1);
|
||||
});
|
||||
|
||||
it('should emit error result', () => {
|
||||
adapter.emitResult({
|
||||
isError: true,
|
||||
errorMessage: 'Test error',
|
||||
durationMs: 500,
|
||||
apiDurationMs: 300,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.is_error).toBe(true);
|
||||
expect(resultMessage.subtype).toBe('error_during_execution');
|
||||
expect(resultMessage.error?.message).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should use provided summary over extracted text', () => {
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
summary: 'Custom summary',
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.result).toBe('Custom summary');
|
||||
});
|
||||
|
||||
it('should include usage information', () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
total_tokens: 150,
|
||||
};
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
usage,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.usage).toEqual(usage);
|
||||
});
|
||||
|
||||
it('should include stats when provided', () => {
|
||||
const stats = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 5,
|
||||
totalSuccess: 4,
|
||||
totalFail: 1,
|
||||
totalDurationMs: 1000,
|
||||
totalDecisions: {
|
||||
accept: 3,
|
||||
reject: 1,
|
||||
modify: 0,
|
||||
auto_accept: 1,
|
||||
},
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 10,
|
||||
totalLinesRemoved: 5,
|
||||
},
|
||||
};
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
stats,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.stats).toEqual(stats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUserMessage', () => {
|
||||
it('should add user message to collection', () => {
|
||||
const parts: Part[] = [{ text: 'Hello user' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const userMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user',
|
||||
);
|
||||
|
||||
expect(userMessage).toBeDefined();
|
||||
expect(Array.isArray(userMessage.message.content)).toBe(true);
|
||||
if (Array.isArray(userMessage.message.content)) {
|
||||
expect(userMessage.message.content).toHaveLength(1);
|
||||
expect(userMessage.message.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Hello user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle parent_tool_use_id', () => {
|
||||
const parts: Part[] = [{ text: 'Tool response' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const userMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user',
|
||||
);
|
||||
|
||||
// emitUserMessage currently sets parent_tool_use_id to null
|
||||
expect(userMessage.parent_tool_use_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolResult', () => {
|
||||
it('should emit tool result message', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const toolResult = parsed.find(
|
||||
(
|
||||
msg: unknown,
|
||||
): msg is { type: 'user'; message: { content: unknown[] } } =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user' &&
|
||||
'message' in msg &&
|
||||
typeof msg.message === 'object' &&
|
||||
msg.message !== null &&
|
||||
'content' in msg.message &&
|
||||
Array.isArray(msg.message.content) &&
|
||||
msg.message.content[0] &&
|
||||
typeof msg.message.content[0] === 'object' &&
|
||||
'type' in msg.message.content[0] &&
|
||||
msg.message.content[0].type === 'tool_result',
|
||||
);
|
||||
|
||||
expect(toolResult).toBeDefined();
|
||||
const block = toolResult.message.content[0] as {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content?: string;
|
||||
is_error?: boolean;
|
||||
};
|
||||
expect(block).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'Tool executed successfully',
|
||||
is_error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark error tool results', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: undefined,
|
||||
error: new Error('Tool failed'),
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const toolResult = parsed.find(
|
||||
(
|
||||
msg: unknown,
|
||||
): msg is { type: 'user'; message: { content: unknown[] } } =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user' &&
|
||||
'message' in msg &&
|
||||
typeof msg.message === 'object' &&
|
||||
msg.message !== null &&
|
||||
'content' in msg.message &&
|
||||
Array.isArray(msg.message.content),
|
||||
);
|
||||
|
||||
const block = toolResult.message.content[0] as {
|
||||
is_error?: boolean;
|
||||
};
|
||||
expect(block.is_error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitSystemMessage', () => {
|
||||
it('should add system message to collection', () => {
|
||||
adapter.emitSystemMessage('test_subtype', { data: 'value' });
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const systemMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'system',
|
||||
);
|
||||
|
||||
expect(systemMessage).toBeDefined();
|
||||
expect(systemMessage.subtype).toBe('test_subtype');
|
||||
expect(systemMessage.data).toEqual({ data: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId and getModel', () => {
|
||||
it('should return session ID from config', () => {
|
||||
expect(adapter.getSessionId()).toBe('test-session-id');
|
||||
expect(mockConfig.getSessionId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return model from config', () => {
|
||||
expect(adapter.getModel()).toBe('test-model');
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple messages in collection', () => {
|
||||
it('should collect all messages and emit as array', () => {
|
||||
adapter.emitSystemMessage('init', {});
|
||||
adapter.emitUserMessage([{ text: 'User input' }]);
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Assistant response',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(3);
|
||||
const systemMsg = parsed[0] as { type?: string };
|
||||
const userMsg = parsed[1] as { type?: string };
|
||||
expect(systemMsg.type).toBe('system');
|
||||
expect(userMsg.type).toBe('user');
|
||||
expect(
|
||||
parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
(msg as { type?: string }).type === 'assistant',
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
(msg as { type?: string }).type === 'result',
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { CLIAssistantMessage, CLIMessage } from '../types.js';
|
||||
import {
|
||||
BaseJsonOutputAdapter,
|
||||
type JsonOutputAdapterInterface,
|
||||
type ResultOptions,
|
||||
} from './BaseJsonOutputAdapter.js';
|
||||
|
||||
/**
|
||||
* JSON output adapter that collects all messages and emits them
|
||||
* as a single JSON array at the end of the turn.
|
||||
* Supports both main agent and subagent messages through distinct APIs.
|
||||
*/
|
||||
export class JsonOutputAdapter
|
||||
extends BaseJsonOutputAdapter
|
||||
implements JsonOutputAdapterInterface
|
||||
{
|
||||
private readonly messages: CLIMessage[] = [];
|
||||
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits message to the messages array (batch mode).
|
||||
* Tracks the last assistant message for efficient result text extraction.
|
||||
*/
|
||||
protected emitMessageImpl(message: CLIMessage): void {
|
||||
this.messages.push(message);
|
||||
// Track assistant messages for result generation
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'assistant'
|
||||
) {
|
||||
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON mode does not emit stream events.
|
||||
*/
|
||||
protected shouldEmitStreamEvents(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||
const message = this.finalizeAssistantMessageInternal(
|
||||
this.mainAgentMessageState,
|
||||
null,
|
||||
);
|
||||
this.updateLastAssistantMessage(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
emitResult(options: ResultOptions): void {
|
||||
const resultMessage = this.buildResultMessage(
|
||||
options,
|
||||
this.lastAssistantMessage,
|
||||
);
|
||||
this.messages.push(resultMessage);
|
||||
|
||||
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
|
||||
const json = JSON.stringify(this.messages);
|
||||
process.stdout.write(`${json}\n`);
|
||||
}
|
||||
|
||||
emitMessage(message: CLIMessage): void {
|
||||
// In JSON mode, messages are collected in the messages array
|
||||
// This is called by the base class's finalizeAssistantMessageInternal
|
||||
// but can also be called directly for user/tool/system messages
|
||||
this.messages.push(message);
|
||||
}
|
||||
}
|
||||
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
StreamJsonInputReader,
|
||||
StreamJsonParseError,
|
||||
type StreamJsonInputMessage,
|
||||
} from './StreamJsonInputReader.js';
|
||||
|
||||
describe('StreamJsonInputReader', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
/**
|
||||
* Test parsing all supported message types in a single test
|
||||
*/
|
||||
it('should parse valid messages of all types', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello world' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
},
|
||||
{
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: { subtype: 'initialize' },
|
||||
},
|
||||
{
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-1',
|
||||
response: { initialized: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'control_cancel_request',
|
||||
request_id: 'req-1',
|
||||
},
|
||||
];
|
||||
|
||||
for (const msg of messages) {
|
||||
input.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
input.end();
|
||||
|
||||
const parsed: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
parsed.push(msg);
|
||||
}
|
||||
|
||||
expect(parsed).toHaveLength(messages.length);
|
||||
expect(parsed).toEqual(messages);
|
||||
});
|
||||
|
||||
it('should parse multiple messages', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const message1 = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: { subtype: 'initialize' },
|
||||
};
|
||||
|
||||
const message2 = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
input.write(JSON.stringify(message1) + '\n');
|
||||
input.write(JSON.stringify(message2) + '\n');
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(message1);
|
||||
expect(messages[1]).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should skip empty lines and trim whitespace', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const message = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
input.write('\n');
|
||||
input.write(' ' + JSON.stringify(message) + ' \n');
|
||||
input.write(' \n');
|
||||
input.write('\t\n');
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(message);
|
||||
});
|
||||
|
||||
/**
|
||||
* Consolidated error handling test cases
|
||||
*/
|
||||
it.each([
|
||||
{
|
||||
name: 'invalid JSON',
|
||||
input: '{"invalid": json}\n',
|
||||
expectedError: 'Failed to parse stream-json line',
|
||||
},
|
||||
{
|
||||
name: 'missing type field',
|
||||
input:
|
||||
JSON.stringify({ session_id: 'test-session', message: 'hello' }) +
|
||||
'\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
{
|
||||
name: 'non-object value (string)',
|
||||
input: '"just a string"\n',
|
||||
expectedError: 'Parsed value is not an object',
|
||||
},
|
||||
{
|
||||
name: 'non-object value (null)',
|
||||
input: 'null\n',
|
||||
expectedError: 'Parsed value is not an object',
|
||||
},
|
||||
{
|
||||
name: 'array value',
|
||||
input: '[1, 2, 3]\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
{
|
||||
name: 'type field not a string',
|
||||
input: JSON.stringify({ type: 123, session_id: 'test-session' }) + '\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
])(
|
||||
'should throw StreamJsonParseError for $name',
|
||||
async ({ input: inputLine, expectedError }) => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
input.write(inputLine);
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(error).toBeInstanceOf(StreamJsonParseError);
|
||||
expect((error as StreamJsonParseError).message).toContain(
|
||||
expectedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should use process.stdin as default input', () => {
|
||||
const reader = new StreamJsonInputReader();
|
||||
// Access private field for testing constructor default parameter
|
||||
expect((reader as unknown as { input: typeof process.stdin }).input).toBe(
|
||||
process.stdin,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided input stream', () => {
|
||||
const customInput = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(customInput);
|
||||
// Access private field for testing constructor parameter
|
||||
expect((reader as unknown as { input: typeof customInput }).input).toBe(
|
||||
customInput,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import type { Readable } from 'node:stream';
|
||||
import process from 'node:process';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
CLIMessage,
|
||||
ControlCancelRequest,
|
||||
} from '../types.js';
|
||||
|
||||
export type StreamJsonInputMessage =
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
|
||||
export class StreamJsonParseError extends Error {}
|
||||
|
||||
export class StreamJsonInputReader {
|
||||
private readonly input: Readable;
|
||||
|
||||
constructor(input: Readable = process.stdin) {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
async *read(): AsyncGenerator<StreamJsonInputMessage> {
|
||||
const rl = createInterface({
|
||||
input: this.input,
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const rawLine of rl) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield this.parse(line);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private parse(line: string): StreamJsonInputMessage {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as StreamJsonInputMessage;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new StreamJsonParseError('Parsed value is not an object');
|
||||
}
|
||||
if (!('type' in parsed) || typeof parsed.type !== 'string') {
|
||||
throw new StreamJsonParseError('Missing required "type" field');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof StreamJsonParseError) {
|
||||
throw error;
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new StreamJsonParseError(
|
||||
`Failed to parse stream-json line: ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
Config,
|
||||
ServerGeminiStreamEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js';
|
||||
|
||||
function createMockConfig(): Config {
|
||||
return {
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('StreamJsonOutputAdapter', () => {
|
||||
let adapter: StreamJsonOutputAdapter;
|
||||
let mockConfig: Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let stdoutWriteSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = createMockConfig();
|
||||
stdoutWriteSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutWriteSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('with partial messages enabled', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||
});
|
||||
|
||||
describe('startAssistantMessage', () => {
|
||||
it('should reset state for new message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'First',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Second',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Second',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent with stream events', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit stream events for text deltas', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
const deltaEventCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaEventCall).toBeDefined();
|
||||
const parsed = JSON.parse(deltaEventCall![0] as string);
|
||||
expect(parsed.event.type).toBe('content_block_delta');
|
||||
expect(parsed.event.delta).toMatchObject({
|
||||
type: 'text_delta',
|
||||
text: 'Hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit message_start event on first content', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'First',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const messageStartCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'message_start'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(messageStartCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit content_block_start for new blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const blockStartCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_start'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(blockStartCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit thinking delta events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking',
|
||||
},
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const deltaCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta' &&
|
||||
parsed.event.delta.type === 'thinking_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit message_stop on finalization', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const messageStopCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'message_stop'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(messageStopCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with partial messages disabled', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should not emit stream events', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const streamEventCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return parsed.type === 'stream_event';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(streamEventCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should still emit final assistant message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const assistantCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return parsed.type === 'assistant';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(assistantCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should append text content from Content events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: ' World',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append citation content from Citation events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Citation,
|
||||
value: 'Citation text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('Citation text'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-string citation values', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Citation,
|
||||
value: 123,
|
||||
} as unknown as ServerGeminiStreamEvent);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should append thinking from Thought events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
thinking: 'Planning: Thinking about the task',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinking with only subject', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append tool use from ToolCallRequest events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
id: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
input: { param1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains text blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains thinking blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool_1',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-2',
|
||||
name: 'test_tool_2',
|
||||
args: { param2: 'value2' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(2);
|
||||
expect(
|
||||
message.message.content.every((block) => block.type === 'tool_use'),
|
||||
).toBe(true);
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should update usage from Finished event', () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 10,
|
||||
totalTokenCount: 160,
|
||||
};
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: undefined,
|
||||
usageMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.usage).toMatchObject({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 10,
|
||||
total_tokens: 160,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore events after finalization', () => {
|
||||
adapter.finalizeAssistantMessage();
|
||||
const originalContent =
|
||||
adapter.finalizeAssistantMessage().message.content;
|
||||
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Should be ignored',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toEqual(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeAssistantMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should build and emit a complete assistant message', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test response',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message.type).toBe('assistant');
|
||||
expect(message.uuid).toBeTruthy();
|
||||
expect(message.session_id).toBe('test-session-id');
|
||||
expect(message.parent_tool_use_id).toBeNull();
|
||||
expect(message.message.role).toBe('assistant');
|
||||
expect(message.message.model).toBe('test-model');
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should emit message to stdout immediately', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.type).toBe('assistant');
|
||||
});
|
||||
|
||||
it('should store message in lastAssistantMessage', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
// Access protected property for testing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((adapter as any).lastAssistantMessage).toEqual(message);
|
||||
});
|
||||
|
||||
it('should return same message on subsequent calls', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message1 = adapter.finalizeAssistantMessage();
|
||||
const message2 = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message1).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should split different block types into separate assistant messages', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0].type).toBe('thinking');
|
||||
|
||||
const assistantMessages = stdoutWriteSpy.mock.calls
|
||||
.map((call: unknown[]) => JSON.parse(call[0] as string))
|
||||
.filter(
|
||||
(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
!('type' in payload) ||
|
||||
(payload as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in payload)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (payload as { message?: unknown }).message;
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message === null ||
|
||||
!('content' in message)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every(
|
||||
(block: unknown) =>
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
const observedTypes = assistantMessages.map(
|
||||
(payload: {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
}) => payload.message.content[0]?.type ?? '',
|
||||
);
|
||||
expect(observedTypes).toEqual(['text', 'thinking']);
|
||||
for (const payload of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
payload.message.content.map((block: { type: string }) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw if message not started', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
expect(() => adapter.finalizeAssistantMessage()).toThrow(
|
||||
'Message not started',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Response text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit success result immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('result');
|
||||
expect(parsed.is_error).toBe(false);
|
||||
expect(parsed.subtype).toBe('success');
|
||||
expect(parsed.result).toBe('Response text');
|
||||
expect(parsed.duration_ms).toBe(1000);
|
||||
expect(parsed.num_turns).toBe(1);
|
||||
});
|
||||
|
||||
it('should emit error result', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: true,
|
||||
errorMessage: 'Test error',
|
||||
durationMs: 500,
|
||||
apiDurationMs: 300,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.is_error).toBe(true);
|
||||
expect(parsed.subtype).toBe('error_during_execution');
|
||||
expect(parsed.error?.message).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should use provided summary over extracted text', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
summary: 'Custom summary',
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.result).toBe('Custom summary');
|
||||
});
|
||||
|
||||
it('should include usage information', () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
total_tokens: 150,
|
||||
};
|
||||
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
usage,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.usage).toEqual(usage);
|
||||
});
|
||||
|
||||
it('should handle result without assistant message', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUserMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit user message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
const parts: Part[] = [{ text: 'Hello user' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('user');
|
||||
expect(Array.isArray(parsed.message.content)).toBe(true);
|
||||
if (Array.isArray(parsed.message.content)) {
|
||||
expect(parsed.message.content).toHaveLength(1);
|
||||
expect(parsed.message.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Hello user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle parent_tool_use_id', () => {
|
||||
const parts: Part[] = [{ text: 'Tool response' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
// emitUserMessage currently sets parent_tool_use_id to null
|
||||
expect(parsed.parent_tool_use_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit tool result message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('user');
|
||||
expect(parsed.parent_tool_use_id).toBeNull();
|
||||
const block = parsed.message.content[0];
|
||||
expect(block).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'Tool executed successfully',
|
||||
is_error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark error tool results', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: undefined,
|
||||
error: new Error('Tool failed'),
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
const block = parsed.message.content[0];
|
||||
expect(block.is_error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitSystemMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit system message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitSystemMessage('test_subtype', { data: 'value' });
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('system');
|
||||
expect(parsed.subtype).toBe('test_subtype');
|
||||
expect(parsed.data).toEqual({ data: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId and getModel', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should return session ID from config', () => {
|
||||
expect(adapter.getSessionId()).toBe('test-session-id');
|
||||
expect(mockConfig.getSessionId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return model from config', () => {
|
||||
expect(adapter.getModel()).toBe('test-model');
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message_id in stream events', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should include message_id in stream events after message starts', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
// Process another event to ensure messageStarted is true
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'More',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
// Find all delta events
|
||||
const deltaCalls = calls.filter((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaCalls.length).toBeGreaterThan(0);
|
||||
// The second delta event should have message_id (after messageStarted becomes true)
|
||||
// message_id is added to the event object, so check parsed.event.message_id
|
||||
if (deltaCalls.length > 1) {
|
||||
const secondDelta = JSON.parse(
|
||||
(deltaCalls[1] as unknown[])[0] as string,
|
||||
);
|
||||
// message_id is on the enriched event object
|
||||
expect(
|
||||
secondDelta.event.message_id || secondDelta.message_id,
|
||||
).toBeTruthy();
|
||||
} else {
|
||||
// If only one delta, check if message_id exists
|
||||
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
|
||||
// message_id is added when messageStarted is true
|
||||
// First event may or may not have it, but subsequent ones should
|
||||
expect(delta.event.message_id || delta.message_id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple text blocks', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should split assistant messages when block types change repeatedly', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text content',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'More text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'More text',
|
||||
});
|
||||
|
||||
const assistantMessages = stdoutWriteSpy.mock.calls
|
||||
.map((call: unknown[]) => JSON.parse(call[0] as string))
|
||||
.filter(
|
||||
(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string; text?: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
!('type' in payload) ||
|
||||
(payload as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in payload)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (payload as { message?: unknown }).message;
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message === null ||
|
||||
!('content' in message)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every(
|
||||
(block: unknown) =>
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(3);
|
||||
const observedTypes = assistantMessages.map(
|
||||
(msg: {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string; text?: string }> };
|
||||
}) => msg.message.content[0]?.type ?? '',
|
||||
);
|
||||
expect(observedTypes).toEqual(['text', 'thinking', 'text']);
|
||||
for (const msg of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
msg.message.content.map((block: { type: string }) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge consecutive text fragments', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: ' ',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'World',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CLIAssistantMessage,
|
||||
CLIMessage,
|
||||
CLIPartialAssistantMessage,
|
||||
ControlMessage,
|
||||
StreamEvent,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolUseBlock,
|
||||
} from '../types.js';
|
||||
import {
|
||||
BaseJsonOutputAdapter,
|
||||
type MessageState,
|
||||
type ResultOptions,
|
||||
type JsonOutputAdapterInterface,
|
||||
} from './BaseJsonOutputAdapter.js';
|
||||
|
||||
/**
|
||||
* Stream JSON output adapter that emits messages immediately
|
||||
* as they are completed during the streaming process.
|
||||
* Supports both main agent and subagent messages through distinct APIs.
|
||||
*/
|
||||
export class StreamJsonOutputAdapter
|
||||
extends BaseJsonOutputAdapter
|
||||
implements JsonOutputAdapterInterface
|
||||
{
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly includePartialMessages: boolean,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits message immediately to stdout (stream mode).
|
||||
*/
|
||||
protected emitMessageImpl(message: CLIMessage | ControlMessage): void {
|
||||
// Track assistant messages for result generation
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'assistant'
|
||||
) {
|
||||
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||
}
|
||||
|
||||
// Emit messages immediately in stream mode
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream mode emits stream events when includePartialMessages is enabled.
|
||||
*/
|
||||
protected shouldEmitStreamEvents(): boolean {
|
||||
return this.includePartialMessages;
|
||||
}
|
||||
|
||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||
const state = this.mainAgentMessageState;
|
||||
if (state.finalized) {
|
||||
return this.buildMessage(null);
|
||||
}
|
||||
state.finalized = true;
|
||||
|
||||
this.finalizePendingBlocks(state, null);
|
||||
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
for (const index of orderedOpenBlocks) {
|
||||
this.onBlockClosed(state, index, null);
|
||||
this.closeBlock(state, index);
|
||||
}
|
||||
|
||||
if (state.messageStarted && this.includePartialMessages) {
|
||||
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
|
||||
}
|
||||
|
||||
const message = this.buildMessage(null);
|
||||
this.updateLastAssistantMessage(message);
|
||||
this.emitMessageImpl(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
emitResult(options: ResultOptions): void {
|
||||
const resultMessage = this.buildResultMessage(
|
||||
options,
|
||||
this.lastAssistantMessage,
|
||||
);
|
||||
this.emitMessageImpl(resultMessage);
|
||||
}
|
||||
|
||||
emitMessage(message: CLIMessage | ControlMessage): void {
|
||||
// In stream mode, emit immediately
|
||||
this.emitMessageImpl(message);
|
||||
}
|
||||
|
||||
send(message: CLIMessage | ControlMessage): void {
|
||||
this.emitMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when text block is created.
|
||||
*/
|
||||
protected override onTextBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: TextBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when text is appended.
|
||||
*/
|
||||
protected override onTextAppended(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
fragment: string,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'text_delta', text: fragment },
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when thinking block is created.
|
||||
*/
|
||||
protected override onThinkingBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: ThinkingBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when thinking is appended.
|
||||
*/
|
||||
protected override onThinkingAppended(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
fragment: string,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'thinking_delta', thinking: fragment },
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when tool_use block is created.
|
||||
*/
|
||||
protected override onToolUseBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: ToolUseBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when tool_use input is set.
|
||||
*/
|
||||
protected override onToolUseInputSet(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
input: unknown,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: JSON.stringify(input),
|
||||
},
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when block is closed.
|
||||
*/
|
||||
protected override onBlockClosed(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
if (this.includePartialMessages) {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit message_start event when message is started.
|
||||
* Only emits for main agent, not for subagents.
|
||||
*/
|
||||
protected override onEnsureMessageStarted(
|
||||
state: MessageState,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
// Only emit message_start for main agent, not for subagents
|
||||
if (parentToolUseId === null) {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: state.messageId!,
|
||||
role: 'assistant',
|
||||
model: this.config.getModel(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits stream events when partial messages are enabled.
|
||||
* This is a private method specific to StreamJsonOutputAdapter.
|
||||
* @param event - Stream event to emit
|
||||
* @param parentToolUseId - null for main agent, string for subagent
|
||||
*/
|
||||
private emitStreamEventIfEnabled(
|
||||
event: StreamEvent,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
if (!this.includePartialMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.getMessageState(parentToolUseId);
|
||||
const enrichedEvent = state.messageStarted
|
||||
? ({ ...event, message_id: state.messageId } as StreamEvent & {
|
||||
message_id: string;
|
||||
})
|
||||
: event;
|
||||
|
||||
const partial: CLIPartialAssistantMessage = {
|
||||
type: 'stream_event',
|
||||
uuid: randomUUID(),
|
||||
session_id: this.getSessionId(),
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
event: enrichedEvent,
|
||||
};
|
||||
this.emitMessageImpl(partial);
|
||||
}
|
||||
}
|
||||
591
packages/cli/src/nonInteractive/session.test.ts
Normal file
591
packages/cli/src/nonInteractive/session.test.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { runNonInteractiveStreamJson } from './session.js';
|
||||
import type {
|
||||
CLIUserMessage,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
} from './types.js';
|
||||
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||
import { ControlDispatcher } from './control/ControlDispatcher.js';
|
||||
import { ControlContext } from './control/ControlContext.js';
|
||||
import { ControlService } from './control/ControlService.js';
|
||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||
|
||||
const runNonInteractiveMock = vi.fn();
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../nonInteractiveCli.js', () => ({
|
||||
runNonInteractive: (...args: unknown[]) => runNonInteractiveMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./io/StreamJsonInputReader.js', () => ({
|
||||
StreamJsonInputReader: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./io/StreamJsonOutputAdapter.js', () => ({
|
||||
StreamJsonOutputAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./control/ControlDispatcher.js', () => ({
|
||||
ControlDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./control/ControlContext.js', () => ({
|
||||
ControlContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./control/ControlService.js', () => ({
|
||||
ControlService: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../ui/utils/ConsolePatcher.js', () => ({
|
||||
ConsolePatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
interface ConfigOverrides {
|
||||
getSessionId?: () => string;
|
||||
getModel?: () => string;
|
||||
getIncludePartialMessages?: () => boolean;
|
||||
getDebugMode?: () => boolean;
|
||||
getApprovalMode?: () => string;
|
||||
getOutputFormat?: () => string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function createConfig(overrides: ConfigOverrides = {}): Config {
|
||||
const base = {
|
||||
getSessionId: () => 'test-session',
|
||||
getModel: () => 'test-model',
|
||||
getIncludePartialMessages: () => false,
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => 'auto',
|
||||
getOutputFormat: () => 'stream-json',
|
||||
};
|
||||
return { ...base, ...overrides } as unknown as Config;
|
||||
}
|
||||
|
||||
function createUserMessage(content: string): CLIUserMessage {
|
||||
return {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createControlRequest(
|
||||
subtype: 'initialize' | 'set_model' | 'interrupt' = 'initialize',
|
||||
): CLIControlRequest {
|
||||
if (subtype === 'set_model') {
|
||||
return {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'set_model',
|
||||
model: 'test-model',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (subtype === 'interrupt') {
|
||||
return {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'interrupt',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'initialize',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createControlResponse(requestId: string): CLIControlResponse {
|
||||
return {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createControlCancel(requestId: string): ControlCancelRequest {
|
||||
return {
|
||||
type: 'control_cancel_request',
|
||||
request_id: requestId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runNonInteractiveStreamJson', () => {
|
||||
let config: Config;
|
||||
let mockInputReader: {
|
||||
read: () => AsyncGenerator<
|
||||
| CLIUserMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest
|
||||
>;
|
||||
};
|
||||
let mockOutputAdapter: {
|
||||
emitResult: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockDispatcher: {
|
||||
dispatch: ReturnType<typeof vi.fn>;
|
||||
handleControlResponse: ReturnType<typeof vi.fn>;
|
||||
handleCancel: ReturnType<typeof vi.fn>;
|
||||
shutdown: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockConsolePatcher: {
|
||||
patch: ReturnType<typeof vi.fn>;
|
||||
cleanup: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
config = createConfig();
|
||||
runNonInteractiveMock.mockReset();
|
||||
|
||||
// Setup mocks
|
||||
mockConsolePatcher = {
|
||||
patch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
(ConsolePatcher as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => mockConsolePatcher,
|
||||
);
|
||||
|
||||
mockOutputAdapter = {
|
||||
emitResult: vi.fn(),
|
||||
} as {
|
||||
emitResult: ReturnType<typeof vi.fn>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
(
|
||||
StreamJsonOutputAdapter as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(() => mockOutputAdapter);
|
||||
|
||||
mockDispatcher = {
|
||||
dispatch: vi.fn().mockResolvedValue(undefined),
|
||||
handleControlResponse: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
};
|
||||
(
|
||||
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(() => mockDispatcher);
|
||||
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => ({}),
|
||||
);
|
||||
(ControlService as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => ({}),
|
||||
);
|
||||
|
||||
mockInputReader = {
|
||||
async *read() {
|
||||
// Default: empty stream
|
||||
// Override in tests as needed
|
||||
},
|
||||
};
|
||||
(
|
||||
StreamJsonInputReader as unknown as ReturnType<typeof vi.fn>
|
||||
).mockImplementation(() => mockInputReader);
|
||||
|
||||
runNonInteractiveMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('initializes session and processes initialize control request', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
|
||||
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('processes user message when received as first message', async () => {
|
||||
const userMessage = createUserMessage('Hello world');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||
const runCall = runNonInteractiveMock.mock.calls[0];
|
||||
expect(runCall[2]).toBe('Hello world'); // Direct text, not processed
|
||||
expect(typeof runCall[3]).toBe('string'); // promptId
|
||||
expect(runCall[4]).toEqual(
|
||||
expect.objectContaining({
|
||||
abortController: expect.any(AbortController),
|
||||
adapter: mockOutputAdapter,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('processes multiple user messages sequentially', async () => {
|
||||
// Initialize first to enable multi-query mode
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage1 = createUserMessage('First message');
|
||||
const userMessage2 = createUserMessage('Second message');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage1;
|
||||
yield userMessage2;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('enqueues user messages received during processing', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage1 = createUserMessage('First message');
|
||||
const userMessage2 = createUserMessage('Second message');
|
||||
|
||||
// Make runNonInteractive take some time to simulate processing
|
||||
runNonInteractiveMock.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||
);
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage1;
|
||||
yield userMessage2;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
// Both messages should be processed
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('processes control request in idle state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const controlRequest = createControlRequest('set_model');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield controlRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(1, initRequest);
|
||||
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(2, controlRequest);
|
||||
});
|
||||
|
||||
it('handles control response in idle state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const controlResponse = createControlResponse('req-2');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield controlResponse;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
|
||||
controlResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles control cancel in idle state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const cancelRequest = createControlCancel('req-2');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield cancelRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.handleCancel).toHaveBeenCalledWith('req-2');
|
||||
});
|
||||
|
||||
it('handles control request during processing state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage = createUserMessage('Process me');
|
||||
const controlRequest = createControlRequest('set_model');
|
||||
|
||||
runNonInteractiveMock.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||
);
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage;
|
||||
yield controlRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(controlRequest);
|
||||
});
|
||||
|
||||
it('handles control response during processing state', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage = createUserMessage('Process me');
|
||||
const controlResponse = createControlResponse('req-1');
|
||||
|
||||
runNonInteractiveMock.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(resolve, 10)),
|
||||
);
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage;
|
||||
yield controlResponse;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
|
||||
controlResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles user message with text content', async () => {
|
||||
const userMessage = createUserMessage('Test message');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.objectContaining({ merged: expect.any(Object) }),
|
||||
'Test message',
|
||||
expect.stringContaining('test-session'),
|
||||
expect.objectContaining({
|
||||
abortController: expect.any(AbortController),
|
||||
adapter: mockOutputAdapter,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles user message with array content blocks', async () => {
|
||||
const userMessage: CLIUserMessage = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'First part' },
|
||||
{ type: 'text', text: 'Second part' },
|
||||
],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.objectContaining({ merged: expect.any(Object) }),
|
||||
'First part\nSecond part',
|
||||
expect.stringContaining('test-session'),
|
||||
expect.objectContaining({
|
||||
abortController: expect.any(AbortController),
|
||||
adapter: mockOutputAdapter,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips user message with no text content', async () => {
|
||||
const userMessage: CLIUserMessage = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles error from processUserMessage', async () => {
|
||||
const userMessage = createUserMessage('Test message');
|
||||
|
||||
const error = new Error('Processing error');
|
||||
runNonInteractiveMock.mockRejectedValue(error);
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
// Error should be caught and handled gracefully
|
||||
});
|
||||
|
||||
it('handles stream error gracefully', async () => {
|
||||
const streamError = new Error('Stream error');
|
||||
// eslint-disable-next-line require-yield
|
||||
mockInputReader.read = async function* () {
|
||||
throw streamError;
|
||||
} as typeof mockInputReader.read;
|
||||
|
||||
await expect(runNonInteractiveStreamJson(config, '')).rejects.toThrow(
|
||||
'Stream error',
|
||||
);
|
||||
|
||||
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops processing when abort signal is triggered', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage = createUserMessage('Test message');
|
||||
|
||||
// Capture abort signal from ControlContext
|
||||
let abortSignal: AbortSignal | null = null;
|
||||
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(options: { abortSignal?: AbortSignal }) => {
|
||||
abortSignal = options.abortSignal ?? null;
|
||||
return {};
|
||||
},
|
||||
);
|
||||
|
||||
// Create input reader that aborts after first message
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
// Abort the signal after initialization
|
||||
if (abortSignal && !abortSignal.aborted) {
|
||||
// The signal doesn't have an abort method, but the controller does
|
||||
// Since we can't access the controller directly, we'll test by
|
||||
// verifying that cleanup happens properly
|
||||
}
|
||||
// Yield second message - if abort works, it should be checked
|
||||
yield userMessage;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
// Verify initialization happened
|
||||
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
|
||||
expect(mockDispatcher.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates unique prompt IDs for each message', async () => {
|
||||
// Initialize first to enable multi-query mode
|
||||
const initRequest = createControlRequest('initialize');
|
||||
const userMessage1 = createUserMessage('First');
|
||||
const userMessage2 = createUserMessage('Second');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
yield userMessage1;
|
||||
yield userMessage2;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
|
||||
const promptId1 = runNonInteractiveMock.mock.calls[0][3] as string;
|
||||
const promptId2 = runNonInteractiveMock.mock.calls[1][3] as string;
|
||||
expect(promptId1).not.toBe(promptId2);
|
||||
expect(promptId1).toContain('test-session');
|
||||
expect(promptId2).toContain('test-session');
|
||||
});
|
||||
|
||||
it('ignores non-initialize control request during initialization', async () => {
|
||||
const controlRequest = createControlRequest('set_model');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield controlRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
// Should not transition to idle since it's not an initialize request
|
||||
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up console patcher on completion', async () => {
|
||||
mockInputReader.read = async function* () {
|
||||
// Empty stream - should complete immediately
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
|
||||
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cleans up output adapter on completion', async () => {
|
||||
mockInputReader.read = async function* () {
|
||||
// Empty stream
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
});
|
||||
|
||||
it('calls dispatcher shutdown on completion', async () => {
|
||||
const initRequest = createControlRequest('initialize');
|
||||
|
||||
mockInputReader.read = async function* () {
|
||||
yield initRequest;
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockDispatcher.shutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles empty stream gracefully', async () => {
|
||||
mockInputReader.read = async function* () {
|
||||
// Empty stream
|
||||
};
|
||||
|
||||
await runNonInteractiveStreamJson(config, '');
|
||||
|
||||
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
721
packages/cli/src/nonInteractive/session.ts
Normal file
721
packages/cli/src/nonInteractive/session.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Stream JSON Runner with Session State Machine
|
||||
*
|
||||
* Handles stream-json input/output format with:
|
||||
* - Initialize handshake
|
||||
* - Message routing (control vs user messages)
|
||||
* - FIFO user message queue
|
||||
* - Sequential message processing
|
||||
* - Graceful shutdown
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
|
||||
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
|
||||
import { ControlContext } from './control/ControlContext.js';
|
||||
import { ControlDispatcher } from './control/ControlDispatcher.js';
|
||||
import { ControlService } from './control/ControlService.js';
|
||||
import type {
|
||||
CLIMessage,
|
||||
CLIUserMessage,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
} from './types.js';
|
||||
import {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
} from './types.js';
|
||||
import { createMinimalSettings } from '../config/settings.js';
|
||||
import { runNonInteractive } from '../nonInteractiveCli.js';
|
||||
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
|
||||
|
||||
const SESSION_STATE = {
|
||||
INITIALIZING: 'initializing',
|
||||
IDLE: 'idle',
|
||||
PROCESSING_QUERY: 'processing_query',
|
||||
SHUTTING_DOWN: 'shutting_down',
|
||||
} as const;
|
||||
|
||||
type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE];
|
||||
|
||||
/**
|
||||
* Message type classification for routing
|
||||
*/
|
||||
type MessageType =
|
||||
| 'control_request'
|
||||
| 'control_response'
|
||||
| 'control_cancel'
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'system'
|
||||
| 'result'
|
||||
| 'stream_event'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Routed message with classification
|
||||
*/
|
||||
interface RoutedMessage {
|
||||
type: MessageType;
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session Manager
|
||||
*
|
||||
* Manages the session lifecycle and message processing state machine.
|
||||
*/
|
||||
class SessionManager {
|
||||
private state: SessionState = SESSION_STATE.INITIALIZING;
|
||||
private userMessageQueue: CLIUserMessage[] = [];
|
||||
private abortController: AbortController;
|
||||
private config: Config;
|
||||
private sessionId: string;
|
||||
private promptIdCounter: number = 0;
|
||||
private inputReader: StreamJsonInputReader;
|
||||
private outputAdapter: StreamJsonOutputAdapter;
|
||||
private controlContext: ControlContext | null = null;
|
||||
private dispatcher: ControlDispatcher | null = null;
|
||||
private controlService: ControlService | null = null;
|
||||
private controlSystemEnabled: boolean | null = null;
|
||||
private debugMode: boolean;
|
||||
private shutdownHandler: (() => void) | null = null;
|
||||
private initialPrompt: CLIUserMessage | null = null;
|
||||
|
||||
constructor(config: Config, initialPrompt?: CLIUserMessage) {
|
||||
this.config = config;
|
||||
this.sessionId = config.getSessionId();
|
||||
this.debugMode = config.getDebugMode();
|
||||
this.abortController = new AbortController();
|
||||
this.initialPrompt = initialPrompt ?? null;
|
||||
|
||||
this.inputReader = new StreamJsonInputReader();
|
||||
this.outputAdapter = new StreamJsonOutputAdapter(
|
||||
config,
|
||||
config.getIncludePartialMessages(),
|
||||
);
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
this.setupSignalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next prompt ID
|
||||
*/
|
||||
private getNextPromptId(): string {
|
||||
this.promptIdCounter++;
|
||||
return `${this.sessionId}########${this.promptIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a message to the appropriate handler based on its type
|
||||
*
|
||||
* Classifies incoming messages and routes them to appropriate handlers.
|
||||
*/
|
||||
private route(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): RoutedMessage {
|
||||
// Check control messages first
|
||||
if (isControlRequest(message)) {
|
||||
return { type: 'control_request', message };
|
||||
}
|
||||
if (isControlResponse(message)) {
|
||||
return { type: 'control_response', message };
|
||||
}
|
||||
if (isControlCancel(message)) {
|
||||
return { type: 'control_cancel', message };
|
||||
}
|
||||
|
||||
// Check data messages
|
||||
if (isCLIUserMessage(message)) {
|
||||
return { type: 'user', message };
|
||||
}
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
return { type: 'assistant', message };
|
||||
}
|
||||
if (isCLISystemMessage(message)) {
|
||||
return { type: 'system', message };
|
||||
}
|
||||
if (isCLIResultMessage(message)) {
|
||||
return { type: 'result', message };
|
||||
}
|
||||
if (isCLIPartialAssistantMessage(message)) {
|
||||
return { type: 'stream_event', message };
|
||||
}
|
||||
|
||||
// Unknown message type
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Unknown message type:',
|
||||
JSON.stringify(message, null, 2),
|
||||
);
|
||||
}
|
||||
return { type: 'unknown', message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message with unified logic for both initial prompt and stream messages.
|
||||
*
|
||||
* Handles:
|
||||
* - Abort check
|
||||
* - First message detection and handling
|
||||
* - Normal message processing
|
||||
* - Shutdown state checks
|
||||
*
|
||||
* @param message - Message to process
|
||||
* @returns true if the calling code should exit (break/return), false to continue
|
||||
*/
|
||||
private async processSingleMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<boolean> {
|
||||
// Check for abort
|
||||
if (this.abortController.signal.aborted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle first message if control system not yet initialized
|
||||
if (this.controlSystemEnabled === null) {
|
||||
const handled = await this.handleFirstMessage(message);
|
||||
if (handled) {
|
||||
// If handled, check if we should shutdown
|
||||
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||
}
|
||||
// If not handled, fall through to normal processing
|
||||
}
|
||||
|
||||
// Process message normally
|
||||
await this.processMessage(message);
|
||||
|
||||
// Check for shutdown after processing
|
||||
return this.state === SESSION_STATE.SHUTTING_DOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point - run the session
|
||||
*/
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Starting session', this.sessionId);
|
||||
}
|
||||
|
||||
// Process initial prompt if provided
|
||||
if (this.initialPrompt !== null) {
|
||||
const shouldExit = await this.processSingleMessage(this.initialPrompt);
|
||||
if (shouldExit) {
|
||||
await this.shutdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Process messages from stream
|
||||
for await (const message of this.inputReader.read()) {
|
||||
const shouldExit = await this.processSingleMessage(message);
|
||||
if (shouldExit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream closed, shutdown
|
||||
await this.shutdown();
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Error:', error);
|
||||
}
|
||||
await this.shutdown();
|
||||
throw error;
|
||||
} finally {
|
||||
// Ensure signal handlers are always cleaned up even if shutdown wasn't called
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
}
|
||||
|
||||
private ensureControlSystem(): void {
|
||||
if (this.controlContext && this.dispatcher && this.controlService) {
|
||||
return;
|
||||
}
|
||||
// The control system follows a strict three-layer architecture:
|
||||
// 1. ControlContext (shared session state)
|
||||
// 2. ControlDispatcher (protocol routing SDK ↔ CLI)
|
||||
// 3. ControlService (programmatic API for CLI runtime)
|
||||
//
|
||||
// Application code MUST interact with the control plane exclusively through
|
||||
// ControlService. ControlDispatcher is reserved for protocol-level message
|
||||
// routing and should never be used directly outside of this file.
|
||||
this.controlContext = new ControlContext({
|
||||
config: this.config,
|
||||
streamJson: this.outputAdapter,
|
||||
sessionId: this.sessionId,
|
||||
abortSignal: this.abortController.signal,
|
||||
permissionMode: this.config.getApprovalMode(),
|
||||
onInterrupt: () => this.handleInterrupt(),
|
||||
});
|
||||
this.dispatcher = new ControlDispatcher(this.controlContext);
|
||||
this.controlService = new ControlService(
|
||||
this.controlContext,
|
||||
this.dispatcher,
|
||||
);
|
||||
}
|
||||
|
||||
private getDispatcher(): ControlDispatcher | null {
|
||||
if (this.controlSystemEnabled !== true) {
|
||||
return null;
|
||||
}
|
||||
if (!this.dispatcher) {
|
||||
this.ensureControlSystem();
|
||||
}
|
||||
return this.dispatcher;
|
||||
}
|
||||
|
||||
private async handleFirstMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<boolean> {
|
||||
const routed = this.route(message);
|
||||
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
this.controlSystemEnabled = true;
|
||||
this.ensureControlSystem();
|
||||
if (request.request.subtype === 'initialize') {
|
||||
await this.dispatcher?.dispatch(request);
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (routed.type === 'user') {
|
||||
this.controlSystemEnabled = false;
|
||||
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||
this.userMessageQueue.push(routed.message as CLIUserMessage);
|
||||
await this.processUserMessageQueue();
|
||||
return true;
|
||||
}
|
||||
|
||||
this.controlSystemEnabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single message from the stream
|
||||
*/
|
||||
private async processMessage(
|
||||
message:
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest,
|
||||
): Promise<void> {
|
||||
const routed = this.route(message);
|
||||
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
`[SessionManager] State: ${this.state}, Message type: ${routed.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (this.state) {
|
||||
case SESSION_STATE.INITIALIZING:
|
||||
await this.handleInitializingState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.IDLE:
|
||||
await this.handleIdleState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.PROCESSING_QUERY:
|
||||
await this.handleProcessingState(routed);
|
||||
break;
|
||||
|
||||
case SESSION_STATE.SHUTTING_DOWN:
|
||||
// Ignore all messages during shutdown
|
||||
break;
|
||||
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustiveCheck: never = this.state;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Unknown state:', _exhaustiveCheck);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in initializing state
|
||||
*/
|
||||
private async handleInitializingState(routed: RoutedMessage): Promise<void> {
|
||||
if (routed.type === 'control_request') {
|
||||
const request = routed.message as CLIControlRequest;
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Control request received before control system initialization',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.request.subtype === 'initialize') {
|
||||
await dispatcher.dispatch(request);
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Initialized, transitioning to idle');
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring non-initialize control request during initialization',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring non-control message during initialization',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in idle state
|
||||
*/
|
||||
private async handleIdleState(routed: RoutedMessage): Promise<void> {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (routed.type === 'control_request') {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Ignoring control request (disabled)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await dispatcher.dispatch(request);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_response') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const response = routed.message as CLIControlResponse;
|
||||
dispatcher.handleControlResponse(response);
|
||||
// Stay in idle state
|
||||
} else if (routed.type === 'control_cancel') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const cancelRequest = routed.message as ControlCancelRequest;
|
||||
dispatcher.handleCancel(cancelRequest.request_id);
|
||||
} else if (routed.type === 'user') {
|
||||
const userMessage = routed.message as CLIUserMessage;
|
||||
this.userMessageQueue.push(userMessage);
|
||||
// Start processing queue
|
||||
await this.processUserMessageQueue();
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring message type in idle state:',
|
||||
routed.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages in processing state
|
||||
*/
|
||||
private async handleProcessingState(routed: RoutedMessage): Promise<void> {
|
||||
const dispatcher = this.getDispatcher();
|
||||
if (routed.type === 'control_request') {
|
||||
if (!dispatcher) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Control request ignored during processing (disabled)',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const request = routed.message as CLIControlRequest;
|
||||
await dispatcher.dispatch(request);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'control_response') {
|
||||
if (!dispatcher) {
|
||||
return;
|
||||
}
|
||||
const response = routed.message as CLIControlResponse;
|
||||
dispatcher.handleControlResponse(response);
|
||||
// Continue processing
|
||||
} else if (routed.type === 'user') {
|
||||
// Enqueue for later
|
||||
const userMessage = routed.message as CLIUserMessage;
|
||||
this.userMessageQueue.push(userMessage);
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Enqueued user message during processing',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Ignoring message type during processing:',
|
||||
routed.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process user message queue (FIFO)
|
||||
*/
|
||||
private async processUserMessageQueue(): Promise<void> {
|
||||
while (
|
||||
this.userMessageQueue.length > 0 &&
|
||||
!this.abortController.signal.aborted
|
||||
) {
|
||||
this.state = SESSION_STATE.PROCESSING_QUERY;
|
||||
const userMessage = this.userMessageQueue.shift()!;
|
||||
|
||||
try {
|
||||
await this.processUserMessage(userMessage);
|
||||
} catch (error) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Error processing user message:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// Send error result
|
||||
this.emitErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
// If control system is disabled (single-query mode) and queue is empty,
|
||||
// automatically shutdown instead of returning to idle
|
||||
if (
|
||||
!this.abortController.signal.aborted &&
|
||||
this.state === SESSION_STATE.PROCESSING_QUERY &&
|
||||
this.controlSystemEnabled === false &&
|
||||
this.userMessageQueue.length === 0
|
||||
) {
|
||||
if (this.debugMode) {
|
||||
console.error(
|
||||
'[SessionManager] Single-query mode: queue processed, shutting down',
|
||||
);
|
||||
}
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
return;
|
||||
}
|
||||
|
||||
// Return to idle after processing queue (for multi-query mode with control system)
|
||||
if (
|
||||
!this.abortController.signal.aborted &&
|
||||
this.state === SESSION_STATE.PROCESSING_QUERY
|
||||
) {
|
||||
this.state = SESSION_STATE.IDLE;
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Queue processed, returning to idle');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single user message
|
||||
*/
|
||||
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
|
||||
const input = extractUserMessageText(userMessage);
|
||||
if (!input) {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] No text content in user message');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const promptId = this.getNextPromptId();
|
||||
|
||||
try {
|
||||
await runNonInteractive(
|
||||
this.config,
|
||||
createMinimalSettings(),
|
||||
input,
|
||||
promptId,
|
||||
{
|
||||
abortController: this.abortController,
|
||||
adapter: this.outputAdapter,
|
||||
controlService: this.controlService ?? undefined,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
// Error already handled by runNonInteractive via adapter.emitResult
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Query execution error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tool results as user message
|
||||
*/
|
||||
private emitErrorResult(
|
||||
error: unknown,
|
||||
numTurns: number = 0,
|
||||
durationMs: number = 0,
|
||||
apiDurationMs: number = 0,
|
||||
): void {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.outputAdapter.emitResult({
|
||||
isError: true,
|
||||
errorMessage: message,
|
||||
durationMs,
|
||||
apiDurationMs,
|
||||
numTurns,
|
||||
usage: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle interrupt control request
|
||||
*/
|
||||
private handleInterrupt(): void {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Interrupt requested');
|
||||
}
|
||||
// Abort current query if processing
|
||||
if (this.state === SESSION_STATE.PROCESSING_QUERY) {
|
||||
this.abortController.abort();
|
||||
this.abortController = new AbortController(); // Create new controller for next query
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup signal handlers for graceful shutdown
|
||||
*/
|
||||
private setupSignalHandlers(): void {
|
||||
this.shutdownHandler = () => {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Shutdown signal received');
|
||||
}
|
||||
this.abortController.abort();
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
};
|
||||
|
||||
process.on('SIGINT', this.shutdownHandler);
|
||||
process.on('SIGTERM', this.shutdownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown session and cleanup resources
|
||||
*/
|
||||
private async shutdown(): Promise<void> {
|
||||
if (this.debugMode) {
|
||||
console.error('[SessionManager] Shutting down');
|
||||
}
|
||||
|
||||
this.state = SESSION_STATE.SHUTTING_DOWN;
|
||||
this.dispatcher?.shutdown();
|
||||
this.cleanupSignalHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove signal handlers to prevent memory leaks
|
||||
*/
|
||||
private cleanupSignalHandlers(): void {
|
||||
if (this.shutdownHandler) {
|
||||
process.removeListener('SIGINT', this.shutdownHandler);
|
||||
process.removeListener('SIGTERM', this.shutdownHandler);
|
||||
this.shutdownHandler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractUserMessageText(message: CLIUserMessage): string | null {
|
||||
const content = message.message.content;
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== 'object') {
|
||||
return '';
|
||||
}
|
||||
if ('type' in block && block.type === 'text' && 'text' in block) {
|
||||
return typeof block.text === 'string' ? block.text : '';
|
||||
}
|
||||
return JSON.stringify(block);
|
||||
})
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
return parts.length > 0 ? parts.join('\n') : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for stream-json mode
|
||||
*
|
||||
* @param config - Configuration object
|
||||
* @param input - Optional initial prompt input to process before reading from stream
|
||||
*/
|
||||
export async function runNonInteractiveStreamJson(
|
||||
config: Config,
|
||||
input: string,
|
||||
): Promise<void> {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
|
||||
try {
|
||||
// Create initial user message from prompt input if provided
|
||||
let initialPrompt: CLIUserMessage | undefined = undefined;
|
||||
if (input && input.trim().length > 0) {
|
||||
const sessionId = config.getSessionId();
|
||||
initialPrompt = {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const manager = new SessionManager(config, initialPrompt);
|
||||
await manager.run();
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
}
|
||||
}
|
||||
509
packages/cli/src/nonInteractive/types.ts
Normal file
509
packages/cli/src/nonInteractive/types.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* Annotation for attaching metadata to content blocks
|
||||
*/
|
||||
export interface Annotation {
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage information types
|
||||
*/
|
||||
export interface Usage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
export interface ExtendedUsage extends Usage {
|
||||
server_tool_use?: {
|
||||
web_search_requests: number;
|
||||
};
|
||||
service_tier?: string;
|
||||
cache_creation?: {
|
||||
ephemeral_1h_input_tokens: number;
|
||||
ephemeral_5m_input_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
webSearchRequests: number;
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission denial information
|
||||
*/
|
||||
export interface CLIPermissionDenial {
|
||||
tool_name: string;
|
||||
tool_use_id: string;
|
||||
tool_input: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block types from Anthropic SDK
|
||||
*/
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ThinkingBlock {
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
signature?: string;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ToolUseBlock {
|
||||
type: 'tool_use';
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export interface ToolResultBlock {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content?: string | ContentBlock[];
|
||||
is_error?: boolean;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export type ContentBlock =
|
||||
| TextBlock
|
||||
| ThinkingBlock
|
||||
| ToolUseBlock
|
||||
| ToolResultBlock;
|
||||
|
||||
/**
|
||||
* Anthropic SDK Message types
|
||||
*/
|
||||
export interface APIUserMessage {
|
||||
role: 'user';
|
||||
content: string | ContentBlock[];
|
||||
}
|
||||
|
||||
export interface APIAssistantMessage {
|
||||
id: string;
|
||||
type: 'message';
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
content: ContentBlock[];
|
||||
stop_reason?: string | null;
|
||||
usage: Usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI Message wrapper types
|
||||
*/
|
||||
export interface CLIUserMessage {
|
||||
type: 'user';
|
||||
uuid?: string;
|
||||
session_id: string;
|
||||
message: APIUserMessage;
|
||||
parent_tool_use_id: string | null;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CLIAssistantMessage {
|
||||
type: 'assistant';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
message: APIAssistantMessage;
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export interface CLISystemMessage {
|
||||
type: 'system';
|
||||
subtype: string;
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
data?: unknown;
|
||||
cwd?: string;
|
||||
tools?: string[];
|
||||
mcp_servers?: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
}>;
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
slash_commands?: string[];
|
||||
apiKeySource?: string;
|
||||
qwen_code_version?: string;
|
||||
output_style?: string;
|
||||
agents?: string[];
|
||||
skills?: string[];
|
||||
capabilities?: Record<string, unknown>;
|
||||
compact_metadata?: {
|
||||
trigger: 'manual' | 'auto';
|
||||
pre_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CLIResultMessageSuccess {
|
||||
type: 'result';
|
||||
subtype: 'success';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
is_error: false;
|
||||
duration_ms: number;
|
||||
duration_api_ms: number;
|
||||
num_turns: number;
|
||||
result: string;
|
||||
usage: ExtendedUsage;
|
||||
modelUsage?: Record<string, ModelUsage>;
|
||||
permission_denials: CLIPermissionDenial[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CLIResultMessageError {
|
||||
type: 'result';
|
||||
subtype: 'error_max_turns' | 'error_during_execution';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
is_error: true;
|
||||
duration_ms: number;
|
||||
duration_api_ms: number;
|
||||
num_turns: number;
|
||||
usage: ExtendedUsage;
|
||||
modelUsage?: Record<string, ModelUsage>;
|
||||
permission_denials: CLIPermissionDenial[];
|
||||
error?: {
|
||||
type?: string;
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError;
|
||||
|
||||
/**
|
||||
* Stream event types for real-time message updates
|
||||
*/
|
||||
export interface MessageStartStreamEvent {
|
||||
type: 'message_start';
|
||||
message: {
|
||||
id: string;
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentBlockStartEvent {
|
||||
type: 'content_block_start';
|
||||
index: number;
|
||||
content_block: ContentBlock;
|
||||
}
|
||||
|
||||
export type ContentBlockDelta =
|
||||
| {
|
||||
type: 'text_delta';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'thinking_delta';
|
||||
thinking: string;
|
||||
}
|
||||
| {
|
||||
type: 'input_json_delta';
|
||||
partial_json: string;
|
||||
};
|
||||
|
||||
export interface ContentBlockDeltaEvent {
|
||||
type: 'content_block_delta';
|
||||
index: number;
|
||||
delta: ContentBlockDelta;
|
||||
}
|
||||
|
||||
export interface ContentBlockStopEvent {
|
||||
type: 'content_block_stop';
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface MessageStopStreamEvent {
|
||||
type: 'message_stop';
|
||||
}
|
||||
|
||||
export type StreamEvent =
|
||||
| MessageStartStreamEvent
|
||||
| ContentBlockStartEvent
|
||||
| ContentBlockDeltaEvent
|
||||
| ContentBlockStopEvent
|
||||
| MessageStopStreamEvent;
|
||||
|
||||
export interface CLIPartialAssistantMessage {
|
||||
type: 'stream_event';
|
||||
uuid: string;
|
||||
session_id: string;
|
||||
event: StreamEvent;
|
||||
parent_tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo';
|
||||
|
||||
/**
|
||||
* Permission suggestion for tool use requests
|
||||
* TODO: Align with `ToolCallConfirmationDetails`
|
||||
*/
|
||||
export interface PermissionSuggestion {
|
||||
type: 'allow' | 'deny' | 'modify';
|
||||
label: string;
|
||||
description?: string;
|
||||
modifiedInput?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook callback placeholder for future implementation
|
||||
*/
|
||||
export interface HookRegistration {
|
||||
event: string;
|
||||
callback_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook callback result placeholder for future implementation
|
||||
*/
|
||||
export interface HookCallbackResult {
|
||||
shouldSkip?: boolean;
|
||||
shouldInterrupt?: boolean;
|
||||
suppressOutput?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CLIControlInterruptRequest {
|
||||
subtype: 'interrupt';
|
||||
}
|
||||
|
||||
export interface CLIControlPermissionRequest {
|
||||
subtype: 'can_use_tool';
|
||||
tool_name: string;
|
||||
tool_use_id: string;
|
||||
input: unknown;
|
||||
permission_suggestions: PermissionSuggestion[] | null;
|
||||
blocked_path: string | null;
|
||||
}
|
||||
|
||||
export interface CLIControlInitializeRequest {
|
||||
subtype: 'initialize';
|
||||
hooks?: HookRegistration[] | null;
|
||||
sdkMcpServers?: string[];
|
||||
}
|
||||
|
||||
export interface CLIControlSetPermissionModeRequest {
|
||||
subtype: 'set_permission_mode';
|
||||
mode: PermissionMode;
|
||||
}
|
||||
|
||||
export interface CLIHookCallbackRequest {
|
||||
subtype: 'hook_callback';
|
||||
callback_id: string;
|
||||
input: unknown;
|
||||
tool_use_id: string | null;
|
||||
}
|
||||
|
||||
export interface CLIControlMcpMessageRequest {
|
||||
subtype: 'mcp_message';
|
||||
server_name: string;
|
||||
message: {
|
||||
jsonrpc?: string;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
id?: string | number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CLIControlSetModelRequest {
|
||||
subtype: 'set_model';
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface CLIControlMcpStatusRequest {
|
||||
subtype: 'mcp_server_status';
|
||||
}
|
||||
|
||||
export interface CLIControlSupportedCommandsRequest {
|
||||
subtype: 'supported_commands';
|
||||
}
|
||||
|
||||
export type ControlRequestPayload =
|
||||
| CLIControlInterruptRequest
|
||||
| CLIControlPermissionRequest
|
||||
| CLIControlInitializeRequest
|
||||
| CLIControlSetPermissionModeRequest
|
||||
| CLIHookCallbackRequest
|
||||
| CLIControlMcpMessageRequest
|
||||
| CLIControlSetModelRequest
|
||||
| CLIControlMcpStatusRequest
|
||||
| CLIControlSupportedCommandsRequest;
|
||||
|
||||
export interface CLIControlRequest {
|
||||
type: 'control_request';
|
||||
request_id: string;
|
||||
request: ControlRequestPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission approval result
|
||||
*/
|
||||
export interface PermissionApproval {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
modifiedInput?: unknown;
|
||||
}
|
||||
|
||||
export interface ControlResponse {
|
||||
subtype: 'success';
|
||||
request_id: string;
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
export interface ControlErrorResponse {
|
||||
subtype: 'error';
|
||||
request_id: string;
|
||||
error: string | { message: string; [key: string]: unknown };
|
||||
}
|
||||
|
||||
export interface CLIControlResponse {
|
||||
type: 'control_response';
|
||||
response: ControlResponse | ControlErrorResponse;
|
||||
}
|
||||
|
||||
export interface ControlCancelRequest {
|
||||
type: 'control_cancel_request';
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
export type ControlMessage =
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
|
||||
/**
|
||||
* Union of all CLI message types
|
||||
*/
|
||||
export type CLIMessage =
|
||||
| CLIUserMessage
|
||||
| CLIAssistantMessage
|
||||
| CLISystemMessage
|
||||
| CLIResultMessage
|
||||
| CLIPartialAssistantMessage;
|
||||
|
||||
/**
|
||||
* Type guard functions for message discrimination
|
||||
*/
|
||||
|
||||
export function isCLIUserMessage(msg: any): msg is CLIUserMessage {
|
||||
return (
|
||||
msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'assistant' &&
|
||||
'uuid' in msg &&
|
||||
'message' in msg &&
|
||||
'session_id' in msg &&
|
||||
'parent_tool_use_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isCLISystemMessage(msg: any): msg is CLISystemMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'system' &&
|
||||
'subtype' in msg &&
|
||||
'uuid' in msg &&
|
||||
'session_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isCLIResultMessage(msg: any): msg is CLIResultMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'result' &&
|
||||
'subtype' in msg &&
|
||||
'duration_ms' in msg &&
|
||||
'is_error' in msg &&
|
||||
'uuid' in msg &&
|
||||
'session_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isCLIPartialAssistantMessage(
|
||||
msg: any,
|
||||
): msg is CLIPartialAssistantMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'stream_event' &&
|
||||
'uuid' in msg &&
|
||||
'session_id' in msg &&
|
||||
'event' in msg &&
|
||||
'parent_tool_use_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isControlRequest(msg: any): msg is CLIControlRequest {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'control_request' &&
|
||||
'request_id' in msg &&
|
||||
'request' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isControlResponse(msg: any): msg is CLIControlResponse {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'control_response' &&
|
||||
'response' in msg
|
||||
);
|
||||
}
|
||||
|
||||
export function isControlCancel(msg: any): msg is ControlCancelRequest {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
msg.type === 'control_cancel_request' &&
|
||||
'request_id' in msg
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block type guards
|
||||
*/
|
||||
|
||||
export function isTextBlock(block: any): block is TextBlock {
|
||||
return block && typeof block === 'object' && block.type === 'text';
|
||||
}
|
||||
|
||||
export function isThinkingBlock(block: any): block is ThinkingBlock {
|
||||
return block && typeof block === 'object' && block.type === 'thinking';
|
||||
}
|
||||
|
||||
export function isToolUseBlock(block: any): block is ToolUseBlock {
|
||||
return block && typeof block === 'object' && block.type === 'tool_use';
|
||||
}
|
||||
|
||||
export function isToolResultBlock(block: any): block is ToolResultBlock {
|
||||
return block && typeof block === 'object' && block.type === 'tool_result';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,16 @@ import {
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
JsonFormatter,
|
||||
uiTelemetryService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||
import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
|
||||
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
||||
import type { ControlService } from './nonInteractive/control/ControlService.js';
|
||||
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
import {
|
||||
handleError,
|
||||
@@ -30,35 +32,93 @@ import {
|
||||
handleCancellationError,
|
||||
handleMaxTurnsExceededError,
|
||||
} from './utils/errors.js';
|
||||
import {
|
||||
normalizePartList,
|
||||
extractPartsFromUserMessage,
|
||||
buildSystemMessage,
|
||||
createTaskToolProgressHandler,
|
||||
computeUsageFromMetrics,
|
||||
} from './utils/nonInteractiveHelpers.js';
|
||||
|
||||
/**
|
||||
* Provides optional overrides for `runNonInteractive` execution.
|
||||
*
|
||||
* @param abortController - Optional abort controller for cancellation.
|
||||
* @param adapter - Optional JSON output adapter for structured output formats.
|
||||
* @param userMessage - Optional CLI user message payload for preformatted input.
|
||||
* @param controlService - Optional control service for future permission handling.
|
||||
*/
|
||||
export interface RunNonInteractiveOptions {
|
||||
abortController?: AbortController;
|
||||
adapter?: JsonOutputAdapterInterface;
|
||||
userMessage?: CLIUserMessage;
|
||||
controlService?: ControlService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the non-interactive CLI flow for a single request.
|
||||
*/
|
||||
export async function runNonInteractive(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
input: string,
|
||||
prompt_id: string,
|
||||
options: RunNonInteractiveOptions = {},
|
||||
): Promise<void> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
// Create output adapter based on format
|
||||
let adapter: JsonOutputAdapterInterface | undefined;
|
||||
const outputFormat = config.getOutputFormat();
|
||||
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (options.adapter) {
|
||||
adapter = options.adapter;
|
||||
} else if (outputFormat === OutputFormat.JSON) {
|
||||
adapter = new JsonOutputAdapter(config);
|
||||
} else if (outputFormat === OutputFormat.STREAM_JSON) {
|
||||
adapter = new StreamJsonOutputAdapter(
|
||||
config,
|
||||
config.getIncludePartialMessages(),
|
||||
);
|
||||
}
|
||||
|
||||
// Get readonly values once at the start
|
||||
const sessionId = config.getSessionId();
|
||||
const permissionMode = config.getApprovalMode() as PermissionMode;
|
||||
|
||||
let turnCount = 0;
|
||||
let totalApiDurationMs = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') {
|
||||
// Exit gracefully if the pipe is closed.
|
||||
process.stdout.removeListener('error', stdoutErrorHandler);
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const abortController = options.abortController ?? new AbortController();
|
||||
|
||||
const abortController = new AbortController();
|
||||
// Setup signal handlers for graceful shutdown
|
||||
const shutdownHandler = () => {
|
||||
if (config.getDebugMode()) {
|
||||
console.error('[runNonInteractive] Shutdown signal received');
|
||||
}
|
||||
abortController.abort();
|
||||
};
|
||||
|
||||
let query: Part[] | undefined;
|
||||
try {
|
||||
process.stdout.on('error', stdoutErrorHandler);
|
||||
|
||||
process.on('SIGINT', shutdownHandler);
|
||||
process.on('SIGTERM', shutdownHandler);
|
||||
|
||||
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
|
||||
options.userMessage,
|
||||
);
|
||||
|
||||
if (!initialPartList) {
|
||||
let slashHandled = false;
|
||||
if (isSlashCommand(input)) {
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
input,
|
||||
@@ -66,15 +126,14 @@ export async function runNonInteractive(
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
// If a slash command is found and returns a prompt, use it.
|
||||
// Otherwise, slashCommandResult fall through to the default prompt
|
||||
// handling.
|
||||
if (slashCommandResult) {
|
||||
query = slashCommandResult as Part[];
|
||||
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
|
||||
initialPartList = slashCommandResult as PartListUnion;
|
||||
slashHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
if (!slashHandled) {
|
||||
const { processedQuery, shouldProceed } = await handleAtCommand({
|
||||
query: input,
|
||||
config,
|
||||
@@ -91,12 +150,26 @@ export async function runNonInteractive(
|
||||
'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
}
|
||||
query = processedQuery as Part[];
|
||||
initialPartList = processedQuery as PartListUnion;
|
||||
}
|
||||
}
|
||||
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
||||
if (!initialPartList) {
|
||||
initialPartList = [{ text: input }];
|
||||
}
|
||||
|
||||
const initialParts = normalizePartList(initialPartList);
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
|
||||
|
||||
if (adapter) {
|
||||
const systemMessage = await buildSystemMessage(
|
||||
config,
|
||||
sessionId,
|
||||
permissionMode,
|
||||
);
|
||||
adapter.emitMessage(systemMessage);
|
||||
}
|
||||
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
@@ -105,43 +178,124 @@ export async function runNonInteractive(
|
||||
) {
|
||||
handleMaxTurnsExceededError(config);
|
||||
}
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
const apiStartTime = Date.now();
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
// Start assistant message for this turn
|
||||
if (adapter) {
|
||||
adapter.startAssistantMessage();
|
||||
}
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
handleCancellationError(config);
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += event.value;
|
||||
} else {
|
||||
process.stdout.write(event.value);
|
||||
if (adapter) {
|
||||
// Use adapter for all event processing
|
||||
adapter.processEvent(event);
|
||||
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
} else {
|
||||
// Text output mode - direct stdout
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
process.stdout.write(event.value);
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize assistant message
|
||||
if (adapter) {
|
||||
adapter.finalizeAssistantMessage();
|
||||
}
|
||||
totalApiDurationMs += Date.now() - apiStartTime;
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const finalRequestInfo = requestInfo;
|
||||
|
||||
/*
|
||||
if (options.controlService) {
|
||||
const permissionResult =
|
||||
await options.controlService.permission.shouldAllowTool(
|
||||
requestInfo,
|
||||
);
|
||||
if (!permissionResult.allowed) {
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
|
||||
permissionResult.message ?? '',
|
||||
);
|
||||
}
|
||||
if (adapter && permissionResult.message) {
|
||||
adapter.emitSystemMessage('tool_denied', {
|
||||
tool: requestInfo.name,
|
||||
message: permissionResult.message,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (permissionResult.updatedArgs) {
|
||||
finalRequestInfo = {
|
||||
...requestInfo,
|
||||
args: permissionResult.updatedArgs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const toolCallUpdateCallback = options.controlService
|
||||
? options.controlService.permission.getToolCallUpdateCallback()
|
||||
: undefined;
|
||||
*/
|
||||
|
||||
// Only pass outputUpdateHandler for Task tool
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
const taskToolProgress = isTaskTool
|
||||
? createTaskToolProgressHandler(
|
||||
config,
|
||||
finalRequestInfo.callId,
|
||||
adapter,
|
||||
)
|
||||
: undefined;
|
||||
const taskToolProgressHandler = taskToolProgress?.handler;
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
finalRequestInfo,
|
||||
abortController.signal,
|
||||
isTaskTool && taskToolProgressHandler
|
||||
? {
|
||||
outputUpdateHandler: taskToolProgressHandler,
|
||||
/*
|
||||
toolCallUpdateCallback
|
||||
? { onToolCallsUpdate: toolCallUpdateCallback }
|
||||
: undefined,
|
||||
*/
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Note: In JSON mode, subagent messages are automatically added to the main
|
||||
// adapter's messages array and will be output together on emitResult()
|
||||
|
||||
if (toolResponse.error) {
|
||||
// In JSON/STREAM_JSON mode, tool errors are tolerated and formatted
|
||||
// as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode
|
||||
// from config and allow the session to continue so the LLM can decide what to do next.
|
||||
// In text mode, we still log the error.
|
||||
handleToolError(
|
||||
requestInfo.name,
|
||||
finalRequestInfo.name,
|
||||
toolResponse.error,
|
||||
config,
|
||||
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
@@ -149,6 +303,13 @@ export async function runNonInteractive(
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
);
|
||||
// Note: We no longer emit a separate system message for tool errors
|
||||
// in JSON/STREAM_JSON mode, as the error is already captured in the
|
||||
// tool_result block with is_error=true.
|
||||
}
|
||||
|
||||
if (adapter) {
|
||||
adapter.emitToolResult(finalRequestInfo, toolResponse);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
@@ -157,20 +318,57 @@ export async function runNonInteractive(
|
||||
}
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
} else {
|
||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
process.stdout.write(formatter.format(responseText, stats));
|
||||
// For JSON and STREAM_JSON modes, compute usage from metrics
|
||||
if (adapter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const usage = computeUsageFromMetrics(metrics);
|
||||
// Get stats for JSON format output
|
||||
const stats =
|
||||
outputFormat === OutputFormat.JSON
|
||||
? uiTelemetryService.getMetrics()
|
||||
: undefined;
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: Date.now() - startTime,
|
||||
apiDurationMs: totalApiDurationMs,
|
||||
numTurns: turnCount,
|
||||
usage,
|
||||
stats,
|
||||
});
|
||||
} else {
|
||||
process.stdout.write('\n'); // Ensure a final newline
|
||||
// Text output mode - no usage needed
|
||||
process.stdout.write('\n');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// For JSON and STREAM_JSON modes, compute usage from metrics
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (adapter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const usage = computeUsageFromMetrics(metrics);
|
||||
// Get stats for JSON format output
|
||||
const stats =
|
||||
outputFormat === OutputFormat.JSON
|
||||
? uiTelemetryService.getMetrics()
|
||||
: undefined;
|
||||
adapter.emitResult({
|
||||
isError: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
apiDurationMs: totalApiDurationMs,
|
||||
numTurns: turnCount,
|
||||
errorMessage: message,
|
||||
usage,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
handleError(error, config);
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
process.stdout.removeListener('error', stdoutErrorHandler);
|
||||
// Cleanup signal handlers
|
||||
process.removeListener('SIGINT', shutdownHandler);
|
||||
process.removeListener('SIGTERM', shutdownHandler);
|
||||
if (isTelemetrySdkInitialized()) {
|
||||
await shutdownTelemetry(config);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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 { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
@@ -83,6 +83,7 @@ describe('errors', () => {
|
||||
mockConfig = {
|
||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
||||
getDebugMode: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
@@ -254,6 +255,11 @@ describe('errors', () => {
|
||||
const toolName = 'test-tool';
|
||||
const toolError = new Error('Tool failed');
|
||||
|
||||
describe('when debug mode is enabled', () => {
|
||||
beforeEach(() => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('in text mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
@@ -261,15 +267,16 @@ describe('errors', () => {
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
});
|
||||
|
||||
it('should log error message to stderr', () => {
|
||||
it('should log error message to stderr and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use resultDisplay when provided', () => {
|
||||
it('should use resultDisplay when provided and not exit', () => {
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
@@ -281,6 +288,7 @@ describe('errors', () => {
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Custom display message',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,68 +299,37 @@ describe('errors', () => {
|
||||
).mockReturnValue(OutputFormat.JSON);
|
||||
});
|
||||
|
||||
it('should format error as JSON and exit with default code', () => {
|
||||
expect(() => {
|
||||
it('should log error message to stderr and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
}).toThrow('process.exit called with code: 54');
|
||||
|
||||
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalToolExecutionError',
|
||||
message: 'Error executing tool test-tool: Tool failed',
|
||||
code: 54,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use custom error code', () => {
|
||||
expect(() => {
|
||||
it('should log error with custom error code and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR');
|
||||
}).toThrow('process.exit called with code: 54');
|
||||
|
||||
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalToolExecutionError',
|
||||
message: 'Error executing tool test-tool: Tool failed',
|
||||
code: 'CUSTOM_TOOL_ERROR',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use numeric error code and exit with that code', () => {
|
||||
expect(() => {
|
||||
it('should log error with numeric error code and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig, 500);
|
||||
}).toThrow('process.exit called with code: 500');
|
||||
|
||||
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalToolExecutionError',
|
||||
message: 'Error executing tool test-tool: Tool failed',
|
||||
code: 500,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer resultDisplay over error message', () => {
|
||||
expect(() => {
|
||||
it('should prefer resultDisplay over error message and not exit', () => {
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
@@ -360,21 +337,99 @@ describe('errors', () => {
|
||||
'DISPLAY_ERROR',
|
||||
'Display message',
|
||||
);
|
||||
}).toThrow('process.exit called with code: 54');
|
||||
|
||||
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalToolExecutionError',
|
||||
message: 'Error executing tool test-tool: Display message',
|
||||
code: 'DISPLAY_ERROR',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'Error executing tool test-tool: Display message',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in STREAM_JSON mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
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,
|
||||
parseAndFormatApiError,
|
||||
FatalTurnLimitedError,
|
||||
FatalToolExecutionError,
|
||||
FatalCancellationError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
@@ -88,32 +87,29 @@ export function handleError(
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @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(
|
||||
toolName: string,
|
||||
toolError: Error,
|
||||
config: Config,
|
||||
errorCode?: string | number,
|
||||
_errorCode?: string | number,
|
||||
resultDisplay?: string,
|
||||
): void {
|
||||
const errorMessage = `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`;
|
||||
const toolExecutionError = new FatalToolExecutionError(errorMessage);
|
||||
|
||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const formattedError = formatter.formatError(
|
||||
toolExecutionError,
|
||||
errorCode ?? toolExecutionError.exitCode,
|
||||
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
||||
);
|
||||
|
||||
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 * as auth from './config/auth.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', () => {
|
||||
let originalEnvGeminiApiKey: string | undefined;
|
||||
@@ -17,8 +20,8 @@ describe('validateNonInterActiveAuth', () => {
|
||||
let originalEnvGcp: string | undefined;
|
||||
let originalEnvOpenAiApiKey: string | undefined;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let processExitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let refreshAuthMock: vi.Mock;
|
||||
let processExitSpy: ReturnType<typeof vi.spyOn<[code?: number], never>>;
|
||||
let refreshAuthMock: ReturnType<typeof vi.fn>;
|
||||
let mockSettings: LoadedSettings;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -33,7 +36,7 @@ describe('validateNonInterActiveAuth', () => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
||||
throw new Error(`process.exit(${code}) called`);
|
||||
});
|
||||
}) as ReturnType<typeof vi.spyOn<[code?: number], never>>;
|
||||
refreshAuthMock = vi.fn().mockResolvedValue('refreshed');
|
||||
mockSettings = {
|
||||
system: { path: '', settings: {} },
|
||||
@@ -235,7 +238,24 @@ describe('validateNonInterActiveAuth', () => {
|
||||
});
|
||||
|
||||
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 = {
|
||||
refreshAuth: refreshAuthMock,
|
||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
|
||||
@@ -244,7 +264,6 @@ describe('validateNonInterActiveAuth', () => {
|
||||
.mockReturnValue({ authType: undefined }),
|
||||
} as unknown as Config;
|
||||
|
||||
let thrown: Error | undefined;
|
||||
try {
|
||||
await validateNonInteractiveAuth(
|
||||
undefined,
|
||||
@@ -252,21 +271,27 @@ describe('validateNonInterActiveAuth', () => {
|
||||
nonInteractiveConfig,
|
||||
mockSettings,
|
||||
);
|
||||
expect.fail('Should have exited');
|
||||
} catch (e) {
|
||||
thrown = e as Error;
|
||||
expect((e as Error).message).toContain('process.exit(1) called');
|
||||
}
|
||||
|
||||
expect(thrown?.message).toBe('process.exit(1) called');
|
||||
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
|
||||
const payload = JSON.parse(errorArg);
|
||||
expect(payload.error.type).toBe('Error');
|
||||
expect(payload.error.code).toBe(1);
|
||||
expect(payload.error.message).toContain(
|
||||
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('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;
|
||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||
|
||||
@@ -278,7 +303,6 @@ describe('validateNonInterActiveAuth', () => {
|
||||
.mockReturnValue({ authType: undefined }),
|
||||
} as unknown as Config;
|
||||
|
||||
let thrown: Error | undefined;
|
||||
try {
|
||||
await validateNonInteractiveAuth(
|
||||
undefined,
|
||||
@@ -286,23 +310,27 @@ describe('validateNonInterActiveAuth', () => {
|
||||
nonInteractiveConfig,
|
||||
mockSettings,
|
||||
);
|
||||
expect.fail('Should have exited');
|
||||
} catch (e) {
|
||||
thrown = e as Error;
|
||||
expect((e as Error).message).toContain('process.exit(1) called');
|
||||
}
|
||||
|
||||
expect(thrown?.message).toBe('process.exit(1) called');
|
||||
{
|
||||
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
|
||||
const payload = JSON.parse(errorArg);
|
||||
expect(payload.error.type).toBe('Error');
|
||||
expect(payload.error.code).toBe(1);
|
||||
expect(payload.error.message).toContain(
|
||||
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('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!');
|
||||
process.env['OPENAI_API_KEY'] = 'fake-key';
|
||||
|
||||
@@ -314,7 +342,6 @@ describe('validateNonInterActiveAuth', () => {
|
||||
.mockReturnValue({ authType: undefined }),
|
||||
} as unknown as Config;
|
||||
|
||||
let thrown: Error | undefined;
|
||||
try {
|
||||
await validateNonInteractiveAuth(
|
||||
AuthType.USE_OPENAI,
|
||||
@@ -322,18 +349,159 @@ describe('validateNonInterActiveAuth', () => {
|
||||
nonInteractiveConfig,
|
||||
mockSettings,
|
||||
);
|
||||
expect.fail('Should have exited');
|
||||
} catch (e) {
|
||||
thrown = e as Error;
|
||||
expect((e as Error).message).toContain('process.exit(1) called');
|
||||
}
|
||||
|
||||
expect(thrown?.message).toBe('process.exit(1) called');
|
||||
{
|
||||
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
|
||||
const payload = JSON.parse(errorArg);
|
||||
expect(payload.error.type).toBe('Error');
|
||||
expect(payload.error.code).toBe(1);
|
||||
expect(payload.error.message).toBe('Auth error!');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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 { validateAuthMethod } from './config/auth.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 {
|
||||
if (process.env['OPENAI_API_KEY']) {
|
||||
@@ -27,7 +29,7 @@ export async function validateNonInteractiveAuth(
|
||||
useExternalAuth: boolean | undefined,
|
||||
nonInteractiveConfig: Config,
|
||||
settings: LoadedSettings,
|
||||
) {
|
||||
): Promise<Config> {
|
||||
try {
|
||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||
if (enforcedType) {
|
||||
@@ -58,15 +60,38 @@ export async function validateNonInteractiveAuth(
|
||||
await nonInteractiveConfig.refreshAuth(authType);
|
||||
return nonInteractiveConfig;
|
||||
} catch (error) {
|
||||
if (nonInteractiveConfig.getOutputFormat() === OutputFormat.JSON) {
|
||||
handleError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
nonInteractiveConfig,
|
||||
1,
|
||||
);
|
||||
const outputFormat = nonInteractiveConfig.getOutputFormat();
|
||||
|
||||
// In JSON and STREAM_JSON modes, emit error result and exit
|
||||
if (
|
||||
outputFormat === OutputFormat.JSON ||
|
||||
outputFormat === OutputFormat.STREAM_JSON
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
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 { SubagentManager } from '../subagents/subagent-manager.js';
|
||||
import {
|
||||
@@ -216,6 +216,7 @@ export interface ConfigParameters {
|
||||
sandbox?: SandboxConfig;
|
||||
targetDir: string;
|
||||
debugMode: boolean;
|
||||
includePartialMessages?: boolean;
|
||||
question?: string;
|
||||
fullContext?: boolean;
|
||||
coreTools?: string[];
|
||||
@@ -290,6 +291,27 @@ export interface ConfigParameters {
|
||||
useSmartEdit?: boolean;
|
||||
output?: OutputSettings;
|
||||
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 {
|
||||
@@ -306,6 +328,9 @@ export class Config {
|
||||
private readonly targetDir: string;
|
||||
private workspaceContext: WorkspaceContext;
|
||||
private readonly debugMode: boolean;
|
||||
private readonly inputFormat: InputFormat;
|
||||
private readonly outputFormat: OutputFormat;
|
||||
private readonly includePartialMessages: boolean;
|
||||
private readonly question: string | undefined;
|
||||
private readonly fullContext: boolean;
|
||||
private readonly coreTools: string[] | undefined;
|
||||
@@ -388,7 +413,6 @@ export class Config {
|
||||
private readonly enableToolOutputTruncation: boolean;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private readonly useSmartEdit: boolean;
|
||||
private readonly outputSettings: OutputSettings;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
@@ -401,6 +425,12 @@ export class Config {
|
||||
params.includeDirectories ?? [],
|
||||
);
|
||||
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.fullContext = params.fullContext ?? false;
|
||||
this.coreTools = params.coreTools;
|
||||
@@ -495,12 +525,9 @@ export class Config {
|
||||
this.extensionManagement = params.extensionManagement ?? true;
|
||||
this.storage = new Storage(this.targetDir);
|
||||
this.vlmSwitchMode = params.vlmSwitchMode;
|
||||
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
this.eventEmitter = params.eventEmitter;
|
||||
this.outputSettings = {
|
||||
format: params.output?.format ?? OutputFormat.TEXT,
|
||||
};
|
||||
|
||||
if (params.contextFileName) {
|
||||
setGeminiMdFilename(params.contextFileName);
|
||||
}
|
||||
@@ -786,6 +813,14 @@ export class Config {
|
||||
return this.showMemoryUsage;
|
||||
}
|
||||
|
||||
getInputFormat(): 'text' | 'stream-json' {
|
||||
return this.inputFormat;
|
||||
}
|
||||
|
||||
getIncludePartialMessages(): boolean {
|
||||
return this.includePartialMessages;
|
||||
}
|
||||
|
||||
getAccessibility(): AccessibilitySettings {
|
||||
return this.accessibility;
|
||||
}
|
||||
@@ -1082,9 +1117,7 @@ export class Config {
|
||||
}
|
||||
|
||||
getOutputFormat(): OutputFormat {
|
||||
return this.outputSettings?.format
|
||||
? this.outputSettings.format
|
||||
: OutputFormat.TEXT;
|
||||
return this.outputFormat;
|
||||
}
|
||||
|
||||
async getGitService(): Promise<GitService> {
|
||||
|
||||
@@ -371,6 +371,8 @@ describe('CoreToolScheduler', () => {
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null, // No client needed for these tests
|
||||
getExcludeTools: () => undefined,
|
||||
isInteractive: () => true,
|
||||
} as unknown as Config;
|
||||
const mockToolRegistry = {
|
||||
getAllToolNames: () => ['list_files', 'read_file', 'write_file'],
|
||||
@@ -400,6 +402,241 @@ describe('CoreToolScheduler', () => {
|
||||
' 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,
|
||||
getUseModelRouter: () => false,
|
||||
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;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
@@ -769,6 +1009,9 @@ describe('CoreToolScheduler edit cancellation', () => {
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
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;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
@@ -1421,6 +1664,9 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
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;
|
||||
|
||||
const testTool = new TestApprovalTool(mockConfig);
|
||||
@@ -1450,7 +1696,10 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
const onAllToolCallsComplete = vi.fn();
|
||||
const onToolCallsUpdate = vi.fn();
|
||||
const pendingConfirmations: Array<
|
||||
(outcome: ToolConfirmationOutcome) => void
|
||||
(
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>
|
||||
> = [];
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
@@ -1521,7 +1770,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
|
||||
// Approve the first tool with ProceedAlways
|
||||
const firstConfirmation = pendingConfirmations[0];
|
||||
firstConfirmation(ToolConfirmationOutcome.ProceedAlways);
|
||||
await firstConfirmation(ToolConfirmationOutcome.ProceedAlways);
|
||||
|
||||
// Wait for all tools to be completed
|
||||
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.
|
||||
* 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 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 {
|
||||
// Use Levenshtein distance to find similar tool names from the registry.
|
||||
const allToolNames = this.toolRegistry.getAllToolNames();
|
||||
|
||||
const matches = allToolNames.map((toolName) => ({
|
||||
@@ -670,8 +674,35 @@ export class CoreToolScheduler {
|
||||
|
||||
const newToolCalls: ToolCall[] = requestsToProcess.map(
|
||||
(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);
|
||||
if (!toolInstance) {
|
||||
// Tool is not in registry and not excluded - likely hallucinated or typo
|
||||
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}`;
|
||||
return {
|
||||
@@ -777,6 +808,32 @@ export class CoreToolScheduler {
|
||||
);
|
||||
this.setStatusInternal(reqInfo.callId, 'scheduled');
|
||||
} 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
|
||||
if (
|
||||
confirmationDetails.type === 'edit' &&
|
||||
|
||||
@@ -9,7 +9,18 @@ import type {
|
||||
ToolCallResponseInfo,
|
||||
Config,
|
||||
} 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.
|
||||
@@ -18,15 +29,21 @@ export async function executeToolCall(
|
||||
config: Config,
|
||||
toolCallRequest: ToolCallRequestInfo,
|
||||
abortSignal: AbortSignal,
|
||||
options: ExecuteToolCallOptions = {},
|
||||
): Promise<ToolCallResponseInfo> {
|
||||
return new Promise<ToolCallResponseInfo>((resolve, reject) => {
|
||||
new CoreToolScheduler({
|
||||
config,
|
||||
getPreferredEditor: () => undefined,
|
||||
onEditorClose: () => {},
|
||||
outputUpdateHandler: options.outputUpdateHandler,
|
||||
onAllToolCallsComplete: async (completedToolCalls) => {
|
||||
if (options.onAllToolCallsComplete) {
|
||||
await options.onAllToolCallsComplete(completedToolCalls);
|
||||
}
|
||||
resolve(completedToolCalls[0].response);
|
||||
},
|
||||
onToolCallsUpdate: options.onToolCallsUpdate,
|
||||
getPreferredEditor: () => undefined,
|
||||
onEditorClose: () => {},
|
||||
})
|
||||
.schedule(toolCallRequest, abortSignal)
|
||||
.catch(reject);
|
||||
|
||||
@@ -6,9 +6,15 @@
|
||||
|
||||
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
|
||||
|
||||
export enum InputFormat {
|
||||
TEXT = 'text',
|
||||
STREAM_JSON = 'stream-json',
|
||||
}
|
||||
|
||||
export enum OutputFormat {
|
||||
TEXT = 'text',
|
||||
JSON = 'json',
|
||||
STREAM_JSON = 'stream-json',
|
||||
}
|
||||
|
||||
export interface JsonError {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
} from '../tools/tools.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export type SubAgentEvent =
|
||||
| 'start'
|
||||
@@ -72,6 +73,7 @@ export interface SubAgentToolResultEvent {
|
||||
name: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
responseParts?: Part[];
|
||||
resultDisplay?: string;
|
||||
durationMs?: number;
|
||||
timestamp: number;
|
||||
|
||||
@@ -619,6 +619,13 @@ export class SubAgentScope {
|
||||
name: toolName,
|
||||
success,
|
||||
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
|
||||
? typeof call.response.resultDisplay === 'string'
|
||||
? call.response.resultDisplay
|
||||
|
||||
@@ -332,7 +332,7 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
|
||||
...this.currentToolCalls![toolCallIndex],
|
||||
status: event.success ? 'success' : 'failed',
|
||||
error: event.error,
|
||||
resultDisplay: event.resultDisplay,
|
||||
responseParts: event.responseParts,
|
||||
};
|
||||
|
||||
this.updateDisplay(
|
||||
|
||||
@@ -14,6 +14,8 @@ export enum ToolErrorType {
|
||||
UNHANDLED_EXCEPTION = 'unhandled_exception',
|
||||
TOOL_NOT_REGISTERED = 'tool_not_registered',
|
||||
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_NOT_FOUND = 'file_not_found',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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 type { DiffUpdateResult } from '../ide/ide-client.js';
|
||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||
@@ -461,6 +461,7 @@ export interface TaskResultDisplay {
|
||||
args?: Record<string, unknown>;
|
||||
result?: string;
|
||||
resultDisplay?: string;
|
||||
responseParts?: Part[];
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user