Headless enhancement: add stream-json as input-format/output-format to support programmatically use (#926)

This commit is contained in:
Kdump
2025-11-21 09:26:05 +08:00
committed by GitHub
parent 442a9aed58
commit 9e5387f159
50 changed files with 14559 additions and 534 deletions

11
.vscode/launch.json vendored
View File

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

View File

@@ -548,12 +548,25 @@ Arguments passed directly when running the CLI can override other configurations
- The prompt is processed within the interactive session, not before it. - The prompt is processed within the interactive session, not before it.
- Cannot be used when piping input from stdin. - Cannot be used when piping input from stdin.
- Example: `qwen -i "explain this code"` - Example: `qwen -i "explain this code"`
- **`--output-format <format>`**: - **`--output-format <format>`** (**`-o <format>`**):
- **Description:** Specifies the format of the CLI output for non-interactive mode. - **Description:** Specifies the format of the CLI output for non-interactive mode.
- **Values:** - **Values:**
- `text`: (Default) The standard human-readable output. - `text`: (Default) The standard human-readable output.
- `json`: A machine-readable JSON output. - `json`: A machine-readable JSON output emitted at the end of execution.
- **Note:** For structured output and scripting, use the `--output-format json` flag. - `stream-json`: Streaming JSON messages emitted as they occur during execution.
- **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless.md) for detailed information.
- **`--input-format <format>`**:
- **Description:** Specifies the format consumed from standard input.
- **Values:**
- `text`: (Default) Standard text input from stdin or command-line arguments.
- `stream-json`: JSON message protocol via stdin for bidirectional communication.
- **Requirement:** `--input-format stream-json` requires `--output-format stream-json` to be set.
- **Note:** When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless.md) for detailed information.
- **`--include-partial-messages`**:
- **Description:** Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming.
- **Default:** `false`
- **Requirement:** Requires `--output-format stream-json` to be set.
- **Note:** See [Headless Mode](../features/headless.md) for detailed information about stream events.
- **`--sandbox`** (**`-s`**): - **`--sandbox`** (**`-s`**):
- Enables sandbox mode for this session. - Enables sandbox mode for this session.
- **`--sandbox-image`**: - **`--sandbox-image`**:

View File

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

View File

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

View File

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

View File

@@ -8,9 +8,16 @@
}, },
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": { "bin": {
"qwen": "dist/index.js" "qwen": "dist/index.js"
}, },
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": { "scripts": {
"build": "node ../../scripts/build_package.js", "build": "node ../../scripts/build_package.js",
"start": "node dist/index.js", "start": "node dist/index.js",

View File

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

View File

@@ -7,7 +7,6 @@
import type { import type {
FileFilteringOptions, FileFilteringOptions,
MCPServerConfig, MCPServerConfig,
OutputFormat,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js'; import { extensionsCommand } from '../commands/extensions.js';
import { import {
@@ -24,6 +23,8 @@ import {
WriteFileTool, WriteFileTool,
resolveTelemetrySettings, resolveTelemetrySettings,
FatalConfigError, FatalConfigError,
InputFormat,
OutputFormat,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import yargs, { type Argv } from 'yargs'; import yargs, { type Argv } from 'yargs';
@@ -124,7 +125,24 @@ export interface CliArgs {
screenReader: boolean | undefined; screenReader: boolean | undefined;
vlmSwitchMode: string | undefined; vlmSwitchMode: string | undefined;
useSmartEdit: boolean | undefined; useSmartEdit: boolean | undefined;
inputFormat?: string | undefined;
outputFormat: string | undefined; outputFormat: string | undefined;
includePartialMessages?: boolean;
}
function normalizeOutputFormat(
format: string | OutputFormat | undefined,
): OutputFormat | undefined {
if (!format) {
return undefined;
}
if (format === OutputFormat.STREAM_JSON) {
return OutputFormat.STREAM_JSON;
}
if (format === 'json' || format === OutputFormat.JSON) {
return OutputFormat.JSON;
}
return OutputFormat.TEXT;
} }
export async function parseArguments(settings: Settings): Promise<CliArgs> { export async function parseArguments(settings: Settings): Promise<CliArgs> {
@@ -359,11 +377,23 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.',
default: process.env['VLM_SWITCH_MODE'], default: process.env['VLM_SWITCH_MODE'],
}) })
.option('input-format', {
type: 'string',
choices: ['text', 'stream-json'],
description: 'The format consumed from standard input.',
default: 'text',
})
.option('output-format', { .option('output-format', {
alias: 'o', alias: 'o',
type: 'string', type: 'string',
description: 'The format of the CLI output.', description: 'The format of the CLI output.',
choices: ['text', 'json'], choices: ['text', 'json', 'stream-json'],
})
.option('include-partial-messages', {
type: 'boolean',
description:
'Include partial assistant messages when using stream-json output.',
default: false,
}) })
.deprecateOption( .deprecateOption(
'show-memory-usage', 'show-memory-usage',
@@ -408,6 +438,18 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
if (argv['yolo'] && argv['approvalMode']) { if (argv['yolo'] && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.'; return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
} }
if (
argv['includePartialMessages'] &&
argv['outputFormat'] !== OutputFormat.STREAM_JSON
) {
return '--include-partial-messages requires --output-format stream-json';
}
if (
argv['inputFormat'] === 'stream-json' &&
argv['outputFormat'] !== OutputFormat.STREAM_JSON
) {
return '--input-format stream-json requires --output-format stream-json';
}
return true; return true;
}), }),
) )
@@ -588,6 +630,22 @@ export async function loadCliConfig(
let mcpServers = mergeMcpServers(settings, activeExtensions); let mcpServers = mergeMcpServers(settings, activeExtensions);
const question = argv.promptInteractive || argv.prompt || ''; const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
const argvOutputFormat = normalizeOutputFormat(
argv.outputFormat as string | OutputFormat | undefined,
);
const settingsOutputFormat = normalizeOutputFormat(settings.output?.format);
const outputFormat =
argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT;
const outputSettingsFormat: OutputFormat =
outputFormat === OutputFormat.STREAM_JSON
? settingsOutputFormat &&
settingsOutputFormat !== OutputFormat.STREAM_JSON
? settingsOutputFormat
: OutputFormat.TEXT
: (outputFormat as OutputFormat);
const includePartialMessages = Boolean(argv.includePartialMessages);
// Determine approval mode with backward compatibility // Determine approval mode with backward compatibility
let approvalMode: ApprovalMode; let approvalMode: ApprovalMode;
@@ -629,11 +687,31 @@ export async function loadCliConfig(
throw err; throw err;
} }
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag) // Interactive mode determination with priority:
// 1. If promptInteractive (-i flag) is provided, it is explicitly interactive
// 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive
// 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive
const hasQuery = !!argv.query; const hasQuery = !!argv.query;
const interactive = const hasPrompt = !!argv.prompt;
!!argv.promptInteractive || let interactive: boolean;
(process.stdin.isTTY && !hasQuery && !argv.prompt); if (argv.promptInteractive) {
// Priority 1: Explicit -i flag means interactive
interactive = true;
} else if (
(outputFormat === OutputFormat.STREAM_JSON ||
outputFormat === OutputFormat.JSON) &&
(hasQuery || hasPrompt)
) {
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
interactive = false;
} else if (!hasQuery && !hasPrompt) {
// Priority 3: No query or prompt means interactive only if TTY (format arguments ignored)
interactive = process.stdin.isTTY ?? false;
} else {
// Default: If we have query/prompt but output format is TEXT, assume non-interactive
// (fallback for edge cases where query/prompt is provided with TEXT output)
interactive = false;
}
// In non-interactive mode, exclude tools that require a prompt. // In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = []; const extraExcludes: string[] = [];
if (!interactive && !argv.experimentalAcp) { if (!interactive && !argv.experimentalAcp) {
@@ -755,6 +833,9 @@ export async function loadCliConfig(
blockedMcpServers, blockedMcpServers,
noBrowser: !!process.env['NO_BROWSER'], noBrowser: !!process.env['NO_BROWSER'],
authType: settings.security?.auth?.selectedType, authType: settings.security?.auth?.selectedType,
inputFormat,
outputFormat,
includePartialMessages,
generationConfig: { generationConfig: {
...(settings.model?.generationConfig || {}), ...(settings.model?.generationConfig || {}),
model: resolvedModel, model: resolvedModel,
@@ -798,7 +879,7 @@ export async function loadCliConfig(
eventEmitter: appEvents, eventEmitter: appEvents,
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
output: { output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, format: outputSettingsFormat,
}, },
}); });
} }

View File

@@ -483,6 +483,27 @@ export class LoadedSettings {
} }
} }
/**
* Creates a minimal LoadedSettings instance with empty settings.
* Used in stream-json mode where settings are ignored.
*/
export function createMinimalSettings(): LoadedSettings {
const emptySettingsFile: SettingsFile = {
path: '',
settings: {},
originalSettings: {},
rawJson: '{}',
};
return new LoadedSettings(
emptySettingsFile,
emptySettingsFile,
emptySettingsFile,
emptySettingsFile,
false,
new Set(),
);
}
function findEnvFile(startDir: string): string | null { function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir); let currentDir = path.resolve(startDir);
while (true) { while (true) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { vi, type MockInstance } from 'vitest'; import { vi, type Mock, type MockInstance } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core'; import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
import { import {
@@ -83,6 +83,7 @@ describe('errors', () => {
mockConfig = { mockConfig = {
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }), getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
getDebugMode: vi.fn().mockReturnValue(true),
} as unknown as Config; } as unknown as Config;
}); });
@@ -254,105 +255,81 @@ describe('errors', () => {
const toolName = 'test-tool'; const toolName = 'test-tool';
const toolError = new Error('Tool failed'); const toolError = new Error('Tool failed');
describe('in text mode', () => { describe('when debug mode is enabled', () => {
beforeEach(() => { beforeEach(() => {
( (mockConfig.getDebugMode as Mock).mockReturnValue(true);
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
}); });
it('should log error message to stderr', () => { describe('in text mode', () => {
handleToolError(toolName, toolError, mockConfig); beforeEach(() => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
});
expect(consoleErrorSpy).toHaveBeenCalledWith( it('should log error message to stderr and not exit', () => {
'Error executing tool test-tool: Tool failed',
);
});
it('should use resultDisplay when provided', () => {
handleToolError(
toolName,
toolError,
mockConfig,
'CUSTOM_ERROR',
'Custom display message',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool test-tool: Custom display message',
);
});
});
describe('in JSON mode', () => {
beforeEach(() => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.JSON);
});
it('should format error as JSON and exit with default code', () => {
expect(() => {
handleToolError(toolName, toolError, mockConfig); handleToolError(toolName, toolError, mockConfig);
}).toThrow('process.exit called with code: 54');
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
JSON.stringify( 'Error executing tool test-tool: Tool failed',
{ );
error: { expect(processExitSpy).not.toHaveBeenCalled();
type: 'FatalToolExecutionError', });
message: 'Error executing tool test-tool: Tool failed',
code: 54, it('should use resultDisplay when provided and not exit', () => {
}, handleToolError(
}, toolName,
null, toolError,
2, mockConfig,
), 'CUSTOM_ERROR',
); 'Custom display message',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool test-tool: Custom display message',
);
expect(processExitSpy).not.toHaveBeenCalled();
});
}); });
it('should use custom error code', () => { describe('in JSON mode', () => {
expect(() => { beforeEach(() => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.JSON);
});
it('should log error message to stderr and not exit', () => {
handleToolError(toolName, toolError, mockConfig);
// In JSON mode, should not exit (just log to stderr when debug mode is on)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool test-tool: Tool failed',
);
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should log error with custom error code and not exit', () => {
handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR'); handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR');
}).toThrow('process.exit called with code: 54');
expect(consoleErrorSpy).toHaveBeenCalledWith( // In JSON mode, should not exit (just log to stderr when debug mode is on)
JSON.stringify( expect(consoleErrorSpy).toHaveBeenCalledWith(
{ 'Error executing tool test-tool: Tool failed',
error: { );
type: 'FatalToolExecutionError', expect(processExitSpy).not.toHaveBeenCalled();
message: 'Error executing tool test-tool: Tool failed', });
code: 'CUSTOM_TOOL_ERROR',
},
},
null,
2,
),
);
});
it('should use numeric error code and exit with that code', () => { it('should log error with numeric error code and not exit', () => {
expect(() => {
handleToolError(toolName, toolError, mockConfig, 500); handleToolError(toolName, toolError, mockConfig, 500);
}).toThrow('process.exit called with code: 500');
expect(consoleErrorSpy).toHaveBeenCalledWith( // In JSON mode, should not exit (just log to stderr when debug mode is on)
JSON.stringify( expect(consoleErrorSpy).toHaveBeenCalledWith(
{ 'Error executing tool test-tool: Tool failed',
error: { );
type: 'FatalToolExecutionError', expect(processExitSpy).not.toHaveBeenCalled();
message: 'Error executing tool test-tool: Tool failed', });
code: 500,
},
},
null,
2,
),
);
});
it('should prefer resultDisplay over error message', () => { it('should prefer resultDisplay over error message and not exit', () => {
expect(() => {
handleToolError( handleToolError(
toolName, toolName,
toolError, toolError,
@@ -360,21 +337,99 @@ describe('errors', () => {
'DISPLAY_ERROR', 'DISPLAY_ERROR',
'Display message', 'Display message',
); );
}).toThrow('process.exit called with code: 54');
expect(consoleErrorSpy).toHaveBeenCalledWith( // In JSON mode, should not exit (just log to stderr when debug mode is on)
JSON.stringify( expect(consoleErrorSpy).toHaveBeenCalledWith(
{ 'Error executing tool test-tool: Display message',
error: { );
type: 'FatalToolExecutionError', expect(processExitSpy).not.toHaveBeenCalled();
message: 'Error executing tool test-tool: Display message', });
code: 'DISPLAY_ERROR', });
},
}, describe('in STREAM_JSON mode', () => {
null, beforeEach(() => {
2, (
), mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
); ).mockReturnValue(OutputFormat.STREAM_JSON);
});
it('should log error message to stderr and not exit', () => {
handleToolError(toolName, toolError, mockConfig);
// Should not exit in STREAM_JSON mode (just log to stderr when debug mode is on)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool test-tool: Tool failed',
);
expect(processExitSpy).not.toHaveBeenCalled();
});
});
});
describe('when debug mode is disabled', () => {
beforeEach(() => {
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
});
it('should not log and not exit in text mode', () => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(toolName, toolError, mockConfig);
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not log and not exit in JSON mode', () => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.JSON);
handleToolError(toolName, toolError, mockConfig);
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
it('should not log and not exit in STREAM_JSON mode', () => {
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.STREAM_JSON);
handleToolError(toolName, toolError, mockConfig);
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
});
});
describe('process exit behavior', () => {
beforeEach(() => {
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
});
it('should never exit regardless of output format', () => {
// Test in TEXT mode
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.TEXT);
handleToolError(toolName, toolError, mockConfig);
expect(processExitSpy).not.toHaveBeenCalled();
// Test in JSON mode
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.JSON);
handleToolError(toolName, toolError, mockConfig);
expect(processExitSpy).not.toHaveBeenCalled();
// Test in STREAM_JSON mode
(
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
).mockReturnValue(OutputFormat.STREAM_JSON);
handleToolError(toolName, toolError, mockConfig);
expect(processExitSpy).not.toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -10,7 +10,6 @@ import {
JsonFormatter, JsonFormatter,
parseAndFormatApiError, parseAndFormatApiError,
FatalTurnLimitedError, FatalTurnLimitedError,
FatalToolExecutionError,
FatalCancellationError, FatalCancellationError,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
@@ -88,32 +87,29 @@ export function handleError(
/** /**
* Handles tool execution errors specifically. * Handles tool execution errors specifically.
* In JSON mode, outputs formatted JSON error and exits. * In JSON/STREAM_JSON mode, outputs error message to stderr only and does not exit.
* The error will be properly formatted in the tool_result block by the adapter,
* allowing the session to continue so the LLM can decide what to do next.
* In text mode, outputs error message to stderr only. * In text mode, outputs error message to stderr only.
*
* @param toolName - Name of the tool that failed
* @param toolError - The error that occurred during tool execution
* @param config - Configuration object
* @param errorCode - Optional error code
* @param resultDisplay - Optional display message for the error
*/ */
export function handleToolError( export function handleToolError(
toolName: string, toolName: string,
toolError: Error, toolError: Error,
config: Config, config: Config,
errorCode?: string | number, _errorCode?: string | number,
resultDisplay?: string, resultDisplay?: string,
): void { ): void {
const errorMessage = `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`; // Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
const toolExecutionError = new FatalToolExecutionError(errorMessage); if (config.getDebugMode()) {
console.error(
if (config.getOutputFormat() === OutputFormat.JSON) { `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
const formatter = new JsonFormatter();
const formattedError = formatter.formatError(
toolExecutionError,
errorCode ?? toolExecutionError.exitCode,
); );
console.error(formattedError);
process.exit(
typeof errorCode === 'number' ? errorCode : toolExecutionError.exitCode,
);
} else {
console.error(errorMessage);
} }
} }

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -10,6 +10,9 @@ import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core';
import * as auth from './config/auth.js'; import * as auth from './config/auth.js';
import { type LoadedSettings } from './config/settings.js'; import { type LoadedSettings } from './config/settings.js';
import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.js';
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
import * as cleanupModule from './utils/cleanup.js';
describe('validateNonInterActiveAuth', () => { describe('validateNonInterActiveAuth', () => {
let originalEnvGeminiApiKey: string | undefined; let originalEnvGeminiApiKey: string | undefined;
@@ -17,8 +20,8 @@ describe('validateNonInterActiveAuth', () => {
let originalEnvGcp: string | undefined; let originalEnvGcp: string | undefined;
let originalEnvOpenAiApiKey: string | undefined; let originalEnvOpenAiApiKey: string | undefined;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>; let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let processExitSpy: ReturnType<typeof vi.spyOn>; let processExitSpy: ReturnType<typeof vi.spyOn<[code?: number], never>>;
let refreshAuthMock: vi.Mock; let refreshAuthMock: ReturnType<typeof vi.fn>;
let mockSettings: LoadedSettings; let mockSettings: LoadedSettings;
beforeEach(() => { beforeEach(() => {
@@ -33,7 +36,7 @@ describe('validateNonInterActiveAuth', () => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code}) called`); throw new Error(`process.exit(${code}) called`);
}); }) as ReturnType<typeof vi.spyOn<[code?: number], never>>;
refreshAuthMock = vi.fn().mockResolvedValue('refreshed'); refreshAuthMock = vi.fn().mockResolvedValue('refreshed');
mockSettings = { mockSettings = {
system: { path: '', settings: {} }, system: { path: '', settings: {} },
@@ -235,7 +238,24 @@ describe('validateNonInterActiveAuth', () => {
}); });
describe('JSON output mode', () => { describe('JSON output mode', () => {
it('prints JSON error when no auth is configured and exits with code 1', async () => { let emitResultMock: ReturnType<typeof vi.fn>;
let runExitCleanupMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
emitResultMock = vi.fn();
runExitCleanupMock = vi.fn().mockResolvedValue(undefined);
vi.spyOn(JsonOutputAdapterModule, 'JsonOutputAdapter').mockImplementation(
() =>
({
emitResult: emitResultMock,
}) as unknown as JsonOutputAdapterModule.JsonOutputAdapter,
);
vi.spyOn(cleanupModule, 'runExitCleanup').mockImplementation(
runExitCleanupMock,
);
});
it('emits error result and exits when no auth is configured', async () => {
const nonInteractiveConfig = { const nonInteractiveConfig = {
refreshAuth: refreshAuthMock, refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
@@ -244,7 +264,6 @@ describe('validateNonInterActiveAuth', () => {
.mockReturnValue({ authType: undefined }), .mockReturnValue({ authType: undefined }),
} as unknown as Config; } as unknown as Config;
let thrown: Error | undefined;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
undefined, undefined,
@@ -252,21 +271,27 @@ describe('validateNonInterActiveAuth', () => {
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
); );
expect.fail('Should have exited');
} catch (e) { } catch (e) {
thrown = e as Error; expect((e as Error).message).toContain('process.exit(1) called');
} }
expect(thrown?.message).toBe('process.exit(1) called'); expect(emitResultMock).toHaveBeenCalledWith({
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; isError: true,
const payload = JSON.parse(errorArg); errorMessage: expect.stringContaining(
expect(payload.error.type).toBe('Error'); 'Please set an Auth method in your',
expect(payload.error.code).toBe(1); ),
expect(payload.error.message).toContain( durationMs: 0,
'Please set an Auth method in your', apiDurationMs: 0,
); numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
}); });
it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => { it('emits error result and exits when enforced auth mismatches current auth', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
@@ -278,7 +303,6 @@ describe('validateNonInterActiveAuth', () => {
.mockReturnValue({ authType: undefined }), .mockReturnValue({ authType: undefined }),
} as unknown as Config; } as unknown as Config;
let thrown: Error | undefined;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
undefined, undefined,
@@ -286,23 +310,27 @@ describe('validateNonInterActiveAuth', () => {
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
); );
expect.fail('Should have exited');
} catch (e) { } catch (e) {
thrown = e as Error; expect((e as Error).message).toContain('process.exit(1) called');
} }
expect(thrown?.message).toBe('process.exit(1) called'); expect(emitResultMock).toHaveBeenCalledWith({
{ isError: true,
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; errorMessage: expect.stringContaining(
const payload = JSON.parse(errorArg);
expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain(
'The configured auth type is qwen-oauth, but the current auth type is openai.', 'The configured auth type is qwen-oauth, but the current auth type is openai.',
); ),
} durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
}); });
it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => { it('emits error result and exits when validateAuthMethod fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key'; process.env['OPENAI_API_KEY'] = 'fake-key';
@@ -314,7 +342,6 @@ describe('validateNonInterActiveAuth', () => {
.mockReturnValue({ authType: undefined }), .mockReturnValue({ authType: undefined }),
} as unknown as Config; } as unknown as Config;
let thrown: Error | undefined;
try { try {
await validateNonInteractiveAuth( await validateNonInteractiveAuth(
AuthType.USE_OPENAI, AuthType.USE_OPENAI,
@@ -322,18 +349,159 @@ describe('validateNonInterActiveAuth', () => {
nonInteractiveConfig, nonInteractiveConfig,
mockSettings, mockSettings,
); );
expect.fail('Should have exited');
} catch (e) { } catch (e) {
thrown = e as Error; expect((e as Error).message).toContain('process.exit(1) called');
} }
expect(thrown?.message).toBe('process.exit(1) called'); expect(emitResultMock).toHaveBeenCalledWith({
{ isError: true,
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; errorMessage: 'Auth error!',
const payload = JSON.parse(errorArg); durationMs: 0,
expect(payload.error.type).toBe('Error'); apiDurationMs: 0,
expect(payload.error.code).toBe(1); numTurns: 0,
expect(payload.error.message).toBe('Auth error!'); usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
describe('STREAM_JSON output mode', () => {
let emitResultMock: ReturnType<typeof vi.fn>;
let runExitCleanupMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
emitResultMock = vi.fn();
runExitCleanupMock = vi.fn().mockResolvedValue(undefined);
vi.spyOn(
StreamJsonOutputAdapterModule,
'StreamJsonOutputAdapter',
).mockImplementation(
() =>
({
emitResult: emitResultMock,
}) as unknown as StreamJsonOutputAdapterModule.StreamJsonOutputAdapter,
);
vi.spyOn(cleanupModule, 'runExitCleanup').mockImplementation(
runExitCleanupMock,
);
});
it('emits error result and exits when no auth is configured', async () => {
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
expect((e as Error).message).toContain('process.exit(1) called');
} }
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: expect.stringContaining(
'Please set an Auth method in your',
),
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('emits error result and exits when enforced auth mismatches current auth', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: expect.stringContaining(
'The configured auth type is qwen-oauth, but the current auth type is openai.',
),
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('emits error result and exits when validateAuthMethod fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
try {
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: 'Auth error!',
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
}); });
}); });
}); });

View File

@@ -9,7 +9,9 @@ import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
import { USER_SETTINGS_PATH } from './config/settings.js'; import { USER_SETTINGS_PATH } from './config/settings.js';
import { validateAuthMethod } from './config/auth.js'; import { validateAuthMethod } from './config/auth.js';
import { type LoadedSettings } from './config/settings.js'; import { type LoadedSettings } from './config/settings.js';
import { handleError } from './utils/errors.js'; import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
import { runExitCleanup } from './utils/cleanup.js';
function getAuthTypeFromEnv(): AuthType | undefined { function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['OPENAI_API_KEY']) { if (process.env['OPENAI_API_KEY']) {
@@ -27,7 +29,7 @@ export async function validateNonInteractiveAuth(
useExternalAuth: boolean | undefined, useExternalAuth: boolean | undefined,
nonInteractiveConfig: Config, nonInteractiveConfig: Config,
settings: LoadedSettings, settings: LoadedSettings,
) { ): Promise<Config> {
try { try {
const enforcedType = settings.merged.security?.auth?.enforcedType; const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType) { if (enforcedType) {
@@ -58,15 +60,38 @@ export async function validateNonInteractiveAuth(
await nonInteractiveConfig.refreshAuth(authType); await nonInteractiveConfig.refreshAuth(authType);
return nonInteractiveConfig; return nonInteractiveConfig;
} catch (error) { } catch (error) {
if (nonInteractiveConfig.getOutputFormat() === OutputFormat.JSON) { const outputFormat = nonInteractiveConfig.getOutputFormat();
handleError(
error instanceof Error ? error : new Error(String(error)), // In JSON and STREAM_JSON modes, emit error result and exit
nonInteractiveConfig, if (
1, outputFormat === OutputFormat.JSON ||
); outputFormat === OutputFormat.STREAM_JSON
} else { ) {
console.error(error instanceof Error ? error.message : String(error)); let adapter;
if (outputFormat === OutputFormat.JSON) {
adapter = new JsonOutputAdapter(nonInteractiveConfig);
} else {
adapter = new StreamJsonOutputAdapter(
nonInteractiveConfig,
nonInteractiveConfig.getIncludePartialMessages(),
);
}
const errorMessage =
error instanceof Error ? error.message : String(error);
adapter.emitResult({
isError: true,
errorMessage,
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
await runExitCleanup();
process.exit(1); process.exit(1);
} }
// For other modes (text), use existing error handling
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
} }
} }

View File

@@ -62,7 +62,7 @@ import { WriteFileTool } from '../tools/write-file.js';
// Other modules // Other modules
import { ideContextStore } from '../ide/ideContext.js'; import { ideContextStore } from '../ide/ideContext.js';
import { OutputFormat } from '../output/types.js'; import { InputFormat, OutputFormat } from '../output/types.js';
import { PromptRegistry } from '../prompts/prompt-registry.js'; import { PromptRegistry } from '../prompts/prompt-registry.js';
import { SubagentManager } from '../subagents/subagent-manager.js'; import { SubagentManager } from '../subagents/subagent-manager.js';
import { import {
@@ -216,6 +216,7 @@ export interface ConfigParameters {
sandbox?: SandboxConfig; sandbox?: SandboxConfig;
targetDir: string; targetDir: string;
debugMode: boolean; debugMode: boolean;
includePartialMessages?: boolean;
question?: string; question?: string;
fullContext?: boolean; fullContext?: boolean;
coreTools?: string[]; coreTools?: string[];
@@ -290,6 +291,27 @@ export interface ConfigParameters {
useSmartEdit?: boolean; useSmartEdit?: boolean;
output?: OutputSettings; output?: OutputSettings;
skipStartupContext?: boolean; skipStartupContext?: boolean;
inputFormat?: InputFormat;
outputFormat?: OutputFormat;
}
function normalizeConfigOutputFormat(
format: OutputFormat | undefined,
): OutputFormat | undefined {
if (!format) {
return undefined;
}
switch (format) {
case 'stream-json':
return OutputFormat.STREAM_JSON;
case 'json':
case OutputFormat.JSON:
return OutputFormat.JSON;
case 'text':
case OutputFormat.TEXT:
default:
return OutputFormat.TEXT;
}
} }
export class Config { export class Config {
@@ -306,6 +328,9 @@ export class Config {
private readonly targetDir: string; private readonly targetDir: string;
private workspaceContext: WorkspaceContext; private workspaceContext: WorkspaceContext;
private readonly debugMode: boolean; private readonly debugMode: boolean;
private readonly inputFormat: InputFormat;
private readonly outputFormat: OutputFormat;
private readonly includePartialMessages: boolean;
private readonly question: string | undefined; private readonly question: string | undefined;
private readonly fullContext: boolean; private readonly fullContext: boolean;
private readonly coreTools: string[] | undefined; private readonly coreTools: string[] | undefined;
@@ -388,7 +413,6 @@ export class Config {
private readonly enableToolOutputTruncation: boolean; private readonly enableToolOutputTruncation: boolean;
private readonly eventEmitter?: EventEmitter; private readonly eventEmitter?: EventEmitter;
private readonly useSmartEdit: boolean; private readonly useSmartEdit: boolean;
private readonly outputSettings: OutputSettings;
constructor(params: ConfigParameters) { constructor(params: ConfigParameters) {
this.sessionId = params.sessionId; this.sessionId = params.sessionId;
@@ -401,6 +425,12 @@ export class Config {
params.includeDirectories ?? [], params.includeDirectories ?? [],
); );
this.debugMode = params.debugMode; this.debugMode = params.debugMode;
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
const normalizedOutputFormat = normalizeConfigOutputFormat(
params.outputFormat ?? params.output?.format,
);
this.outputFormat = normalizedOutputFormat ?? OutputFormat.TEXT;
this.includePartialMessages = params.includePartialMessages ?? false;
this.question = params.question; this.question = params.question;
this.fullContext = params.fullContext ?? false; this.fullContext = params.fullContext ?? false;
this.coreTools = params.coreTools; this.coreTools = params.coreTools;
@@ -495,12 +525,9 @@ export class Config {
this.extensionManagement = params.extensionManagement ?? true; this.extensionManagement = params.extensionManagement ?? true;
this.storage = new Storage(this.targetDir); this.storage = new Storage(this.targetDir);
this.vlmSwitchMode = params.vlmSwitchMode; this.vlmSwitchMode = params.vlmSwitchMode;
this.inputFormat = params.inputFormat ?? InputFormat.TEXT;
this.fileExclusions = new FileExclusions(this); this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter; this.eventEmitter = params.eventEmitter;
this.outputSettings = {
format: params.output?.format ?? OutputFormat.TEXT,
};
if (params.contextFileName) { if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName); setGeminiMdFilename(params.contextFileName);
} }
@@ -786,6 +813,14 @@ export class Config {
return this.showMemoryUsage; return this.showMemoryUsage;
} }
getInputFormat(): 'text' | 'stream-json' {
return this.inputFormat;
}
getIncludePartialMessages(): boolean {
return this.includePartialMessages;
}
getAccessibility(): AccessibilitySettings { getAccessibility(): AccessibilitySettings {
return this.accessibility; return this.accessibility;
} }
@@ -1082,9 +1117,7 @@ export class Config {
} }
getOutputFormat(): OutputFormat { getOutputFormat(): OutputFormat {
return this.outputSettings?.format return this.outputFormat;
? this.outputSettings.format
: OutputFormat.TEXT;
} }
async getGitService(): Promise<GitService> { async getGitService(): Promise<GitService> {

View File

@@ -371,6 +371,8 @@ describe('CoreToolScheduler', () => {
getUseSmartEdit: () => false, getUseSmartEdit: () => false,
getUseModelRouter: () => false, getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests getGeminiClient: () => null, // No client needed for these tests
getExcludeTools: () => undefined,
isInteractive: () => true,
} as unknown as Config; } as unknown as Config;
const mockToolRegistry = { const mockToolRegistry = {
getAllToolNames: () => ['list_files', 'read_file', 'write_file'], getAllToolNames: () => ['list_files', 'read_file', 'write_file'],
@@ -400,6 +402,241 @@ describe('CoreToolScheduler', () => {
' Did you mean one of: "list_files", "read_file", "write_file"?', ' Did you mean one of: "list_files", "read_file", "write_file"?',
); );
}); });
it('should use Levenshtein suggestions for excluded tools (getToolSuggestion only handles non-excluded)', () => {
// Create mocked tool registry
const mockToolRegistry = {
getAllToolNames: () => ['list_files', 'read_file'],
} as unknown as ToolRegistry;
// Create mocked config with excluded tools
const mockConfig = {
getToolRegistry: () => mockToolRegistry,
getUseSmartEdit: () => false,
getUseModelRouter: () => false,
getGeminiClient: () => null,
getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'],
isInteractive: () => false, // Value doesn't matter, but included for completeness
} as unknown as Config;
// Create scheduler
const scheduler = new CoreToolScheduler({
config: mockConfig,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
// getToolSuggestion no longer handles excluded tools - it only handles truly missing tools
// So excluded tools will use Levenshtein distance to find similar registered tools
// @ts-expect-error accessing private method
const excludedTool = scheduler.getToolSuggestion('write_file');
expect(excludedTool).toContain('Did you mean');
});
it('should use Levenshtein suggestions for non-excluded tools', () => {
// Create mocked tool registry
const mockToolRegistry = {
getAllToolNames: () => ['list_files', 'read_file'],
} as unknown as ToolRegistry;
// Create mocked config with excluded tools
const mockConfig = {
getToolRegistry: () => mockToolRegistry,
getUseSmartEdit: () => false,
getUseModelRouter: () => false,
getGeminiClient: () => null,
getExcludeTools: () => ['write_file', 'edit'],
isInteractive: () => false, // Value doesn't matter
} as unknown as Config;
// Create scheduler
const scheduler = new CoreToolScheduler({
config: mockConfig,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
// Test that non-excluded tool (hallucinated) still uses Levenshtein suggestions
// @ts-expect-error accessing private method
const hallucinatedTool = scheduler.getToolSuggestion('list_fils');
expect(hallucinatedTool).toContain('Did you mean');
expect(hallucinatedTool).not.toContain(
'not available in the current environment',
);
});
});
describe('excluded tools handling', () => {
it('should return permission error for excluded tools instead of "not found" message', async () => {
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const mockToolRegistry = {
getTool: () => undefined, // Tool not in registry
getAllToolNames: () => ['list_files', 'read_file'],
getFunctionDeclarations: () => [],
tools: new Map(),
discovery: {},
registerTool: () => {},
getToolByName: () => undefined,
getToolByDisplayName: () => undefined,
getTools: () => [],
discoverTools: async () => {},
getAllTools: () => [],
getToolsByServer: () => [],
} as unknown as ToolRegistry;
const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getAllowedTools: () => [],
getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'],
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'oauth-personal',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
terminalHeight: 30,
}),
storage: {
getProjectTempDir: () => '/tmp',
},
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getToolRegistry: () => mockToolRegistry,
getUseSmartEdit: () => false,
getUseModelRouter: () => false,
getGeminiClient: () => null,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();
const request = {
callId: '1',
name: 'write_file', // Excluded tool
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-excluded',
};
await scheduler.schedule([request], abortController.signal);
// Wait for completion
await vi.waitFor(() => {
expect(onAllToolCallsComplete).toHaveBeenCalled();
});
const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as ToolCall[];
expect(completedCalls).toHaveLength(1);
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('error');
if (completedCall.status === 'error') {
const errorMessage = completedCall.response.error?.message;
expect(errorMessage).toBe(
'Qwen Code requires permission to use write_file, but that permission was declined.',
);
// Should NOT contain "not found in registry"
expect(errorMessage).not.toContain('not found in registry');
}
});
it('should return "not found" message for truly missing tools (not excluded)', async () => {
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const mockToolRegistry = {
getTool: () => undefined, // Tool not in registry
getAllToolNames: () => ['list_files', 'read_file'],
getFunctionDeclarations: () => [],
tools: new Map(),
discovery: {},
registerTool: () => {},
getToolByName: () => undefined,
getToolByDisplayName: () => undefined,
getTools: () => [],
discoverTools: async () => {},
getAllTools: () => [],
getToolsByServer: () => [],
} as unknown as ToolRegistry;
const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getAllowedTools: () => [],
getExcludeTools: () => ['write_file', 'edit'], // Different excluded tools
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'oauth-personal',
}),
getShellExecutionConfig: () => ({
terminalWidth: 90,
terminalHeight: 30,
}),
storage: {
getProjectTempDir: () => '/tmp',
},
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getToolRegistry: () => mockToolRegistry,
getUseSmartEdit: () => false,
getUseModelRouter: () => false,
getGeminiClient: () => null,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();
const request = {
callId: '1',
name: 'nonexistent_tool', // Not excluded, just doesn't exist
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-missing',
};
await scheduler.schedule([request], abortController.signal);
// Wait for completion
await vi.waitFor(() => {
expect(onAllToolCallsComplete).toHaveBeenCalled();
});
const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as ToolCall[];
expect(completedCalls).toHaveLength(1);
const completedCall = completedCalls[0];
expect(completedCall.status).toBe('error');
if (completedCall.status === 'error') {
const errorMessage = completedCall.response.error?.message;
// Should contain "not found in registry"
expect(errorMessage).toContain('not found in registry');
// Should NOT contain permission message
expect(errorMessage).not.toContain('requires permission');
}
});
}); });
}); });
@@ -449,6 +686,9 @@ describe('CoreToolScheduler with payload', () => {
getUseSmartEdit: () => false, getUseSmartEdit: () => false,
getUseModelRouter: () => false, getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests getGeminiClient: () => null, // No client needed for these tests
isInteractive: () => true, // Required to prevent auto-denial of tool calls
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
} as unknown as Config; } as unknown as Config;
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
@@ -769,6 +1009,9 @@ describe('CoreToolScheduler edit cancellation', () => {
getUseSmartEdit: () => false, getUseSmartEdit: () => false,
getUseModelRouter: () => false, getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests getGeminiClient: () => null, // No client needed for these tests
isInteractive: () => true, // Required to prevent auto-denial of tool calls
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
} as unknown as Config; } as unknown as Config;
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
@@ -1421,6 +1664,9 @@ describe('CoreToolScheduler request queueing', () => {
getUseSmartEdit: () => false, getUseSmartEdit: () => false,
getUseModelRouter: () => false, getUseModelRouter: () => false,
getGeminiClient: () => null, // No client needed for these tests getGeminiClient: () => null, // No client needed for these tests
isInteractive: () => true, // Required to prevent auto-denial of tool calls
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
} as unknown as Config; } as unknown as Config;
const testTool = new TestApprovalTool(mockConfig); const testTool = new TestApprovalTool(mockConfig);
@@ -1450,7 +1696,10 @@ describe('CoreToolScheduler request queueing', () => {
const onAllToolCallsComplete = vi.fn(); const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn(); const onToolCallsUpdate = vi.fn();
const pendingConfirmations: Array< const pendingConfirmations: Array<
(outcome: ToolConfirmationOutcome) => void (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>
> = []; > = [];
const scheduler = new CoreToolScheduler({ const scheduler = new CoreToolScheduler({
@@ -1521,7 +1770,7 @@ describe('CoreToolScheduler request queueing', () => {
// Approve the first tool with ProceedAlways // Approve the first tool with ProceedAlways
const firstConfirmation = pendingConfirmations[0]; const firstConfirmation = pendingConfirmations[0];
firstConfirmation(ToolConfirmationOutcome.ProceedAlways); await firstConfirmation(ToolConfirmationOutcome.ProceedAlways);
// Wait for all tools to be completed // Wait for all tools to be completed
await vi.waitFor(() => { await vi.waitFor(() => {

View File

@@ -587,12 +587,16 @@ export class CoreToolScheduler {
/** /**
* Generates a suggestion string for a tool name that was not found in the registry. * Generates a suggestion string for a tool name that was not found in the registry.
* It finds the closest matches based on Levenshtein distance. * Uses Levenshtein distance to suggest similar tool names for hallucinated or misspelled tools.
* Note: Excluded tools are handled separately before calling this method, so this only
* handles the case where a tool is truly not found (hallucinated or typo).
* @param unknownToolName The tool name that was not found. * @param unknownToolName The tool name that was not found.
* @param topN The number of suggestions to return. Defaults to 3. * @param topN The number of suggestions to return. Defaults to 3.
* @returns A suggestion string like " Did you mean 'tool'?" or " Did you mean one of: 'tool1', 'tool2'?", or an empty string if no suggestions are found. * @returns A suggestion string like " Did you mean 'tool'?" or " Did you mean one of: 'tool1', 'tool2'?",
* or an empty string if no suggestions are found.
*/ */
private getToolSuggestion(unknownToolName: string, topN = 3): string { private getToolSuggestion(unknownToolName: string, topN = 3): string {
// Use Levenshtein distance to find similar tool names from the registry.
const allToolNames = this.toolRegistry.getAllToolNames(); const allToolNames = this.toolRegistry.getAllToolNames();
const matches = allToolNames.map((toolName) => ({ const matches = allToolNames.map((toolName) => ({
@@ -670,8 +674,35 @@ export class CoreToolScheduler {
const newToolCalls: ToolCall[] = requestsToProcess.map( const newToolCalls: ToolCall[] = requestsToProcess.map(
(reqInfo): ToolCall => { (reqInfo): ToolCall => {
// Check if the tool is excluded due to permissions/environment restrictions
// This check should happen before registry lookup to provide a clear permission error
const excludeTools = this.config.getExcludeTools?.() ?? undefined;
if (excludeTools && excludeTools.length > 0) {
const normalizedToolName = reqInfo.name.toLowerCase().trim();
const excludedMatch = excludeTools.find(
(excludedTool) =>
excludedTool.toLowerCase().trim() === normalizedToolName,
);
if (excludedMatch) {
// The tool exists but is excluded - return permission error directly
const permissionErrorMessage = `Qwen Code requires permission to use ${excludedMatch}, but that permission was declined.`;
return {
status: 'error',
request: reqInfo,
response: createErrorResponse(
reqInfo,
new Error(permissionErrorMessage),
ToolErrorType.EXECUTION_DENIED,
),
durationMs: 0,
};
}
}
const toolInstance = this.toolRegistry.getTool(reqInfo.name); const toolInstance = this.toolRegistry.getTool(reqInfo.name);
if (!toolInstance) { if (!toolInstance) {
// Tool is not in registry and not excluded - likely hallucinated or typo
const suggestion = this.getToolSuggestion(reqInfo.name); const suggestion = this.getToolSuggestion(reqInfo.name);
const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`; const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`;
return { return {
@@ -777,6 +808,32 @@ export class CoreToolScheduler {
); );
this.setStatusInternal(reqInfo.callId, 'scheduled'); this.setStatusInternal(reqInfo.callId, 'scheduled');
} else { } else {
/**
* In non-interactive mode where no user will respond to approval prompts,
* and not running as IDE companion or Zed integration, automatically deny approval.
* This is intended to create an explicit denial of the tool call,
* rather than silently waiting for approval and hanging forever.
*/
const shouldAutoDeny =
!this.config.isInteractive() &&
!this.config.getIdeMode() &&
!this.config.getExperimentalZedIntegration();
if (shouldAutoDeny) {
// Treat as execution denied error, similar to excluded tools
const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`;
this.setStatusInternal(
reqInfo.callId,
'error',
createErrorResponse(
reqInfo,
new Error(errorMessage),
ToolErrorType.EXECUTION_DENIED,
),
);
continue;
}
// Allow IDE to resolve confirmation // Allow IDE to resolve confirmation
if ( if (
confirmationDetails.type === 'edit' && confirmationDetails.type === 'edit' &&

View File

@@ -9,7 +9,18 @@ import type {
ToolCallResponseInfo, ToolCallResponseInfo,
Config, Config,
} from '../index.js'; } from '../index.js';
import { CoreToolScheduler } from './coreToolScheduler.js'; import {
CoreToolScheduler,
type AllToolCallsCompleteHandler,
type OutputUpdateHandler,
type ToolCallsUpdateHandler,
} from './coreToolScheduler.js';
export interface ExecuteToolCallOptions {
outputUpdateHandler?: OutputUpdateHandler;
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
onToolCallsUpdate?: ToolCallsUpdateHandler;
}
/** /**
* Executes a single tool call non-interactively by leveraging the CoreToolScheduler. * Executes a single tool call non-interactively by leveraging the CoreToolScheduler.
@@ -18,15 +29,21 @@ export async function executeToolCall(
config: Config, config: Config,
toolCallRequest: ToolCallRequestInfo, toolCallRequest: ToolCallRequestInfo,
abortSignal: AbortSignal, abortSignal: AbortSignal,
options: ExecuteToolCallOptions = {},
): Promise<ToolCallResponseInfo> { ): Promise<ToolCallResponseInfo> {
return new Promise<ToolCallResponseInfo>((resolve, reject) => { return new Promise<ToolCallResponseInfo>((resolve, reject) => {
new CoreToolScheduler({ new CoreToolScheduler({
config, config,
getPreferredEditor: () => undefined, outputUpdateHandler: options.outputUpdateHandler,
onEditorClose: () => {},
onAllToolCallsComplete: async (completedToolCalls) => { onAllToolCallsComplete: async (completedToolCalls) => {
if (options.onAllToolCallsComplete) {
await options.onAllToolCallsComplete(completedToolCalls);
}
resolve(completedToolCalls[0].response); resolve(completedToolCalls[0].response);
}, },
onToolCallsUpdate: options.onToolCallsUpdate,
getPreferredEditor: () => undefined,
onEditorClose: () => {},
}) })
.schedule(toolCallRequest, abortSignal) .schedule(toolCallRequest, abortSignal)
.catch(reject); .catch(reject);

View File

@@ -6,9 +6,15 @@
import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
export enum InputFormat {
TEXT = 'text',
STREAM_JSON = 'stream-json',
}
export enum OutputFormat { export enum OutputFormat {
TEXT = 'text', TEXT = 'text',
JSON = 'json', JSON = 'json',
STREAM_JSON = 'stream-json',
} }
export interface JsonError { export interface JsonError {

View File

@@ -9,6 +9,7 @@ import type {
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
} from '../tools/tools.js'; } from '../tools/tools.js';
import type { Part } from '@google/genai';
export type SubAgentEvent = export type SubAgentEvent =
| 'start' | 'start'
@@ -72,6 +73,7 @@ export interface SubAgentToolResultEvent {
name: string; name: string;
success: boolean; success: boolean;
error?: string; error?: string;
responseParts?: Part[];
resultDisplay?: string; resultDisplay?: string;
durationMs?: number; durationMs?: number;
timestamp: number; timestamp: number;

View File

@@ -619,6 +619,13 @@ export class SubAgentScope {
name: toolName, name: toolName,
success, success,
error: errorMessage, error: errorMessage,
responseParts: call.response.responseParts,
/**
* Tools like todoWrite will add some extra contents to the result,
* making it unable to deserialize the `responseParts` to a JSON object.
* While `resultDisplay` is normally a string, if not we stringify it,
* so that we can deserialize it to a JSON object when needed.
*/
resultDisplay: call.response.resultDisplay resultDisplay: call.response.resultDisplay
? typeof call.response.resultDisplay === 'string' ? typeof call.response.resultDisplay === 'string'
? call.response.resultDisplay ? call.response.resultDisplay

View File

@@ -332,7 +332,7 @@ class TaskToolInvocation extends BaseToolInvocation<TaskParams, ToolResult> {
...this.currentToolCalls![toolCallIndex], ...this.currentToolCalls![toolCallIndex],
status: event.success ? 'success' : 'failed', status: event.success ? 'success' : 'failed',
error: event.error, error: event.error,
resultDisplay: event.resultDisplay, responseParts: event.responseParts,
}; };
this.updateDisplay( this.updateDisplay(

View File

@@ -14,6 +14,8 @@ export enum ToolErrorType {
UNHANDLED_EXCEPTION = 'unhandled_exception', UNHANDLED_EXCEPTION = 'unhandled_exception',
TOOL_NOT_REGISTERED = 'tool_not_registered', TOOL_NOT_REGISTERED = 'tool_not_registered',
EXECUTION_FAILED = 'execution_failed', EXECUTION_FAILED = 'execution_failed',
// Try to execute a tool that is excluded due to the approval mode
EXECUTION_DENIED = 'execution_denied',
// File System Errors // File System Errors
FILE_NOT_FOUND = 'file_not_found', FILE_NOT_FOUND = 'file_not_found',

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { FunctionDeclaration, PartListUnion } from '@google/genai'; import type { FunctionDeclaration, Part, PartListUnion } from '@google/genai';
import { ToolErrorType } from './tool-error.js'; import { ToolErrorType } from './tool-error.js';
import type { DiffUpdateResult } from '../ide/ide-client.js'; import type { DiffUpdateResult } from '../ide/ide-client.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
@@ -461,6 +461,7 @@ export interface TaskResultDisplay {
args?: Record<string, unknown>; args?: Record<string, unknown>;
result?: string; result?: string;
resultDisplay?: string; resultDisplay?: string;
responseParts?: Part[];
description?: string; description?: string;
}>; }>;
} }