mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
fix: subagent tool call messages
This commit is contained in:
@@ -661,7 +661,7 @@ export async function loadCliConfig(
|
|||||||
// Interactive mode determination with priority:
|
// Interactive mode determination with priority:
|
||||||
// 1. If promptInteractive (-i flag) is provided, it is explicitly interactive
|
// 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
|
// 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, the format arguments should be ignored, it is 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 hasPrompt = !!argv.prompt;
|
const hasPrompt = !!argv.prompt;
|
||||||
let interactive: boolean;
|
let interactive: boolean;
|
||||||
@@ -676,8 +676,8 @@ export async function loadCliConfig(
|
|||||||
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
|
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
|
||||||
interactive = false;
|
interactive = false;
|
||||||
} else if (!hasQuery && !hasPrompt) {
|
} else if (!hasQuery && !hasPrompt) {
|
||||||
// Priority 3: No query or prompt means interactive (format arguments ignored)
|
// Priority 3: No query or prompt means interactive only if TTY (format arguments ignored)
|
||||||
interactive = true;
|
interactive = process.stdin.isTTY ?? false;
|
||||||
} else {
|
} else {
|
||||||
// Default: If we have query/prompt but output format is TEXT, assume non-interactive
|
// 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)
|
// (fallback for edge cases where query/prompt is provided with TEXT output)
|
||||||
|
|||||||
1155
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
1155
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -564,7 +564,7 @@ describe('JsonOutputAdapter', () => {
|
|||||||
|
|
||||||
it('should handle parent_tool_use_id', () => {
|
it('should handle parent_tool_use_id', () => {
|
||||||
const parts: Part[] = [{ text: 'Tool response' }];
|
const parts: Part[] = [{ text: 'Tool response' }];
|
||||||
adapter.emitUserMessage(parts, 'tool-id-1');
|
adapter.emitUserMessage(parts);
|
||||||
|
|
||||||
adapter.emitResult({
|
adapter.emitResult({
|
||||||
isError: false,
|
isError: false,
|
||||||
@@ -583,7 +583,8 @@ describe('JsonOutputAdapter', () => {
|
|||||||
msg.type === 'user',
|
msg.type === 'user',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(userMessage.parent_tool_use_id).toBe('tool-id-1');
|
// emitUserMessage currently sets parent_tool_use_id to null
|
||||||
|
expect(userMessage.parent_tool_use_id).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,521 +4,78 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
import type {
|
import type { CLIAssistantMessage, CLIMessage } from '../types.js';
|
||||||
Config,
|
import {
|
||||||
ServerGeminiStreamEvent,
|
BaseJsonOutputAdapter,
|
||||||
SessionMetrics,
|
type JsonOutputAdapterInterface,
|
||||||
ToolCallRequestInfo,
|
type ResultOptions,
|
||||||
ToolCallResponseInfo,
|
} from './BaseJsonOutputAdapter.js';
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
|
||||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
|
||||||
import type {
|
|
||||||
CLIAssistantMessage,
|
|
||||||
CLIResultMessage,
|
|
||||||
CLIResultMessageError,
|
|
||||||
CLIResultMessageSuccess,
|
|
||||||
CLIUserMessage,
|
|
||||||
ContentBlock,
|
|
||||||
ExtendedUsage,
|
|
||||||
TextBlock,
|
|
||||||
ThinkingBlock,
|
|
||||||
ToolResultBlock,
|
|
||||||
ToolUseBlock,
|
|
||||||
Usage,
|
|
||||||
} from '../types.js';
|
|
||||||
|
|
||||||
export interface ResultOptions {
|
|
||||||
readonly isError: boolean;
|
|
||||||
readonly errorMessage?: string;
|
|
||||||
readonly durationMs: number;
|
|
||||||
readonly apiDurationMs: number;
|
|
||||||
readonly numTurns: number;
|
|
||||||
readonly usage?: ExtendedUsage;
|
|
||||||
readonly totalCostUsd?: number;
|
|
||||||
readonly stats?: SessionMetrics;
|
|
||||||
readonly summary?: string;
|
|
||||||
readonly subtype?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface for message emission strategies.
|
|
||||||
* Implementations decide whether to emit messages immediately (streaming)
|
|
||||||
* or collect them for batch emission (non-streaming).
|
|
||||||
*/
|
|
||||||
export interface MessageEmitter {
|
|
||||||
emitMessage(message: unknown): void;
|
|
||||||
emitUserMessage(parts: Part[], parentToolUseId?: string | null): void;
|
|
||||||
emitToolResult(
|
|
||||||
request: ToolCallRequestInfo,
|
|
||||||
response: ToolCallResponseInfo,
|
|
||||||
): void;
|
|
||||||
emitSystemMessage(subtype: string, data?: unknown): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON-focused output adapter interface.
|
|
||||||
* Handles structured JSON output for both streaming and non-streaming modes.
|
|
||||||
*/
|
|
||||||
export interface JsonOutputAdapterInterface extends MessageEmitter {
|
|
||||||
startAssistantMessage(): void;
|
|
||||||
processEvent(event: ServerGeminiStreamEvent): void;
|
|
||||||
finalizeAssistantMessage(): CLIAssistantMessage;
|
|
||||||
emitResult(options: ResultOptions): void;
|
|
||||||
getSessionId(): string;
|
|
||||||
getModel(): string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON output adapter that collects all messages and emits them
|
* JSON output adapter that collects all messages and emits them
|
||||||
* as a single JSON array at the end of the turn.
|
* as a single JSON array at the end of the turn.
|
||||||
|
* Supports both main agent and subagent messages through distinct APIs.
|
||||||
*/
|
*/
|
||||||
export class JsonOutputAdapter implements JsonOutputAdapterInterface {
|
export class JsonOutputAdapter
|
||||||
private readonly messages: unknown[] = [];
|
extends BaseJsonOutputAdapter
|
||||||
|
implements JsonOutputAdapterInterface
|
||||||
|
{
|
||||||
|
private readonly messages: CLIMessage[] = [];
|
||||||
|
|
||||||
// Assistant message building state
|
constructor(config: Config) {
|
||||||
private messageId: string | null = null;
|
super(config);
|
||||||
private blocks: ContentBlock[] = [];
|
|
||||||
private openBlocks = new Set<number>();
|
|
||||||
private usage: Usage = this.createUsage();
|
|
||||||
private messageStarted = false;
|
|
||||||
private finalized = false;
|
|
||||||
private currentBlockType: ContentBlock['type'] | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly config: Config) {}
|
|
||||||
|
|
||||||
private createUsage(
|
|
||||||
metadata?: GenerateContentResponseUsageMetadata | null,
|
|
||||||
): Usage {
|
|
||||||
const usage: Usage = {
|
|
||||||
input_tokens: 0,
|
|
||||||
output_tokens: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!metadata) {
|
|
||||||
return usage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof metadata.promptTokenCount === 'number') {
|
/**
|
||||||
usage.input_tokens = metadata.promptTokenCount;
|
* Emits message to the messages array (batch mode).
|
||||||
}
|
* Tracks the last assistant message for efficient result text extraction.
|
||||||
if (typeof metadata.candidatesTokenCount === 'number') {
|
*/
|
||||||
usage.output_tokens = metadata.candidatesTokenCount;
|
protected emitMessageImpl(message: CLIMessage): void {
|
||||||
}
|
this.messages.push(message);
|
||||||
if (typeof metadata.cachedContentTokenCount === 'number') {
|
// Track assistant messages for result generation
|
||||||
usage.cache_read_input_tokens = metadata.cachedContentTokenCount;
|
if (
|
||||||
}
|
typeof message === 'object' &&
|
||||||
if (typeof metadata.totalTokenCount === 'number') {
|
message !== null &&
|
||||||
usage.total_tokens = metadata.totalTokenCount;
|
'type' in message &&
|
||||||
}
|
message.type === 'assistant'
|
||||||
|
) {
|
||||||
return usage;
|
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||||
}
|
|
||||||
|
|
||||||
private buildMessage(): CLIAssistantMessage {
|
|
||||||
if (!this.messageId) {
|
|
||||||
throw new Error('Message not started');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce constraint: assistant message must contain only a single type of ContentBlock
|
|
||||||
if (this.blocks.length > 0) {
|
|
||||||
const blockTypes = new Set(this.blocks.map((block) => block.type));
|
|
||||||
if (blockTypes.size > 1) {
|
|
||||||
throw new Error(
|
|
||||||
`Assistant message must contain only one type of ContentBlock, found: ${Array.from(blockTypes).join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine stop_reason based on content block types
|
/**
|
||||||
// If the message contains only tool_use blocks, set stop_reason to 'tool_use'
|
* JSON mode does not emit stream events.
|
||||||
const stopReason =
|
*/
|
||||||
this.blocks.length > 0 &&
|
protected shouldEmitStreamEvents(): boolean {
|
||||||
this.blocks.every((block) => block.type === 'tool_use')
|
return false;
|
||||||
? 'tool_use'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'assistant',
|
|
||||||
uuid: this.messageId,
|
|
||||||
session_id: this.config.getSessionId(),
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
message: {
|
|
||||||
id: this.messageId,
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
model: this.config.getModel(),
|
|
||||||
content: this.blocks,
|
|
||||||
stop_reason: stopReason,
|
|
||||||
usage: this.usage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendText(fragment: string): void {
|
|
||||||
if (fragment.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ensureBlockTypeConsistency('text');
|
|
||||||
this.ensureMessageStarted();
|
|
||||||
|
|
||||||
let current = this.blocks[this.blocks.length - 1];
|
|
||||||
if (!current || current.type !== 'text') {
|
|
||||||
current = { type: 'text', text: '' } satisfies TextBlock;
|
|
||||||
const index = this.blocks.length;
|
|
||||||
this.blocks.push(current);
|
|
||||||
this.openBlock(index, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
current.text += fragment;
|
|
||||||
// JSON mode doesn't emit partial messages, so we skip emitStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendThinking(subject?: string, description?: string): void {
|
|
||||||
this.ensureMessageStarted();
|
|
||||||
|
|
||||||
const fragment = [subject?.trim(), description?.trim()]
|
|
||||||
.filter((value) => value && value.length > 0)
|
|
||||||
.join(': ');
|
|
||||||
if (!fragment) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ensureBlockTypeConsistency('thinking');
|
|
||||||
this.ensureMessageStarted();
|
|
||||||
|
|
||||||
let current = this.blocks[this.blocks.length - 1];
|
|
||||||
if (!current || current.type !== 'thinking') {
|
|
||||||
current = {
|
|
||||||
type: 'thinking',
|
|
||||||
thinking: '',
|
|
||||||
signature: subject,
|
|
||||||
} satisfies ThinkingBlock;
|
|
||||||
const index = this.blocks.length;
|
|
||||||
this.blocks.push(current);
|
|
||||||
this.openBlock(index, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
current.thinking = `${current.thinking ?? ''}${fragment}`;
|
|
||||||
// JSON mode doesn't emit partial messages, so we skip emitStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendToolUse(request: ToolCallRequestInfo): void {
|
|
||||||
this.ensureBlockTypeConsistency('tool_use');
|
|
||||||
this.ensureMessageStarted();
|
|
||||||
this.finalizePendingBlocks();
|
|
||||||
|
|
||||||
const index = this.blocks.length;
|
|
||||||
const block: ToolUseBlock = {
|
|
||||||
type: 'tool_use',
|
|
||||||
id: request.callId,
|
|
||||||
name: request.name,
|
|
||||||
input: request.args,
|
|
||||||
};
|
|
||||||
this.blocks.push(block);
|
|
||||||
this.openBlock(index, block);
|
|
||||||
// JSON mode doesn't emit partial messages, so we skip emitStreamEvent
|
|
||||||
this.closeBlock(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureMessageStarted(): void {
|
|
||||||
if (this.messageStarted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.messageStarted = true;
|
|
||||||
// JSON mode doesn't emit partial messages, so we skip emitStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
private finalizePendingBlocks(): void {
|
|
||||||
const lastBlock = this.blocks[this.blocks.length - 1];
|
|
||||||
if (!lastBlock) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastBlock.type === 'text') {
|
|
||||||
const index = this.blocks.length - 1;
|
|
||||||
this.closeBlock(index);
|
|
||||||
} else if (lastBlock.type === 'thinking') {
|
|
||||||
const index = this.blocks.length - 1;
|
|
||||||
this.closeBlock(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private openBlock(index: number, _block: ContentBlock): void {
|
|
||||||
this.openBlocks.add(index);
|
|
||||||
// JSON mode doesn't emit partial messages, so we skip emitStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
private closeBlock(index: number): void {
|
|
||||||
if (!this.openBlocks.has(index)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.openBlocks.delete(index);
|
|
||||||
// JSON mode doesn't emit partial messages, so we skip emitStreamEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
startAssistantMessage(): void {
|
|
||||||
// Reset state for new message
|
|
||||||
this.messageId = randomUUID();
|
|
||||||
this.blocks = [];
|
|
||||||
this.openBlocks = new Set<number>();
|
|
||||||
this.usage = this.createUsage();
|
|
||||||
this.messageStarted = false;
|
|
||||||
this.finalized = false;
|
|
||||||
this.currentBlockType = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
processEvent(event: ServerGeminiStreamEvent): void {
|
|
||||||
if (this.finalized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case GeminiEventType.Content:
|
|
||||||
this.appendText(event.value);
|
|
||||||
break;
|
|
||||||
case GeminiEventType.Citation:
|
|
||||||
if (typeof event.value === 'string') {
|
|
||||||
this.appendText(`\n${event.value}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case GeminiEventType.Thought:
|
|
||||||
this.appendThinking(event.value.subject, event.value.description);
|
|
||||||
break;
|
|
||||||
case GeminiEventType.ToolCallRequest:
|
|
||||||
this.appendToolUse(event.value);
|
|
||||||
break;
|
|
||||||
case GeminiEventType.Finished:
|
|
||||||
if (event.value?.usageMetadata) {
|
|
||||||
this.usage = this.createUsage(event.value.usageMetadata);
|
|
||||||
}
|
|
||||||
this.finalizePendingBlocks();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||||
if (this.finalized) {
|
const message = this.finalizeAssistantMessageInternal(
|
||||||
return this.buildMessage();
|
this.mainAgentMessageState,
|
||||||
}
|
null,
|
||||||
this.finalized = true;
|
);
|
||||||
|
this.updateLastAssistantMessage(message);
|
||||||
this.finalizePendingBlocks();
|
|
||||||
const orderedOpenBlocks = Array.from(this.openBlocks).sort((a, b) => a - b);
|
|
||||||
for (const index of orderedOpenBlocks) {
|
|
||||||
this.closeBlock(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = this.buildMessage();
|
|
||||||
this.emitMessage(message);
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
emitResult(options: ResultOptions): void {
|
emitResult(options: ResultOptions): void {
|
||||||
const usage = options.usage ?? createExtendedUsage();
|
const resultMessage = this.buildResultMessage(
|
||||||
const resultText = options.summary ?? this.extractResponseText();
|
options,
|
||||||
|
this.lastAssistantMessage,
|
||||||
// Create the final result message to append to the messages array
|
);
|
||||||
const baseUuid = randomUUID();
|
|
||||||
const baseSessionId = this.getSessionId();
|
|
||||||
|
|
||||||
let resultMessage: CLIResultMessage;
|
|
||||||
if (options.isError) {
|
|
||||||
const errorMessage = options.errorMessage ?? 'Unknown error';
|
|
||||||
const errorResult: CLIResultMessageError = {
|
|
||||||
type: 'result',
|
|
||||||
subtype:
|
|
||||||
(options.subtype as CLIResultMessageError['subtype']) ??
|
|
||||||
'error_during_execution',
|
|
||||||
uuid: baseUuid,
|
|
||||||
session_id: baseSessionId,
|
|
||||||
is_error: true,
|
|
||||||
duration_ms: options.durationMs,
|
|
||||||
duration_api_ms: options.apiDurationMs,
|
|
||||||
num_turns: options.numTurns,
|
|
||||||
total_cost_usd: options.totalCostUsd ?? 0,
|
|
||||||
usage,
|
|
||||||
permission_denials: [],
|
|
||||||
error: { message: errorMessage },
|
|
||||||
};
|
|
||||||
resultMessage = errorResult;
|
|
||||||
} else {
|
|
||||||
const success: CLIResultMessageSuccess & { stats?: SessionMetrics } = {
|
|
||||||
type: 'result',
|
|
||||||
subtype:
|
|
||||||
(options.subtype as CLIResultMessageSuccess['subtype']) ?? 'success',
|
|
||||||
uuid: baseUuid,
|
|
||||||
session_id: baseSessionId,
|
|
||||||
is_error: false,
|
|
||||||
duration_ms: options.durationMs,
|
|
||||||
duration_api_ms: options.apiDurationMs,
|
|
||||||
num_turns: options.numTurns,
|
|
||||||
result: resultText,
|
|
||||||
total_cost_usd: options.totalCostUsd ?? 0,
|
|
||||||
usage,
|
|
||||||
permission_denials: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Include stats if available
|
|
||||||
if (options.stats) {
|
|
||||||
success.stats = options.stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultMessage = success;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the result message to the messages array
|
|
||||||
this.messages.push(resultMessage);
|
this.messages.push(resultMessage);
|
||||||
|
|
||||||
// Emit the entire messages array as JSON
|
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
|
||||||
const json = JSON.stringify(this.messages);
|
const json = JSON.stringify(this.messages);
|
||||||
process.stdout.write(`${json}\n`);
|
process.stdout.write(`${json}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitMessage(message: unknown): void {
|
emitMessage(message: CLIMessage): void {
|
||||||
// Stash messages instead of emitting immediately
|
// 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);
|
this.messages.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitUserMessage(parts: Part[], parentToolUseId: string | null = null): void {
|
|
||||||
const content = partsToString(parts);
|
|
||||||
const message: CLIUserMessage = {
|
|
||||||
type: 'user',
|
|
||||||
uuid: randomUUID(),
|
|
||||||
session_id: this.getSessionId(),
|
|
||||||
parent_tool_use_id: parentToolUseId,
|
|
||||||
message: {
|
|
||||||
role: 'user',
|
|
||||||
content,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.emitMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitToolResult(
|
|
||||||
request: ToolCallRequestInfo,
|
|
||||||
response: ToolCallResponseInfo,
|
|
||||||
): void {
|
|
||||||
const block: ToolResultBlock = {
|
|
||||||
type: 'tool_result',
|
|
||||||
tool_use_id: request.callId,
|
|
||||||
is_error: Boolean(response.error),
|
|
||||||
};
|
|
||||||
const content = toolResultContent(response);
|
|
||||||
if (content !== undefined) {
|
|
||||||
block.content = content;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message: CLIUserMessage = {
|
|
||||||
type: 'user',
|
|
||||||
uuid: randomUUID(),
|
|
||||||
session_id: this.getSessionId(),
|
|
||||||
parent_tool_use_id: request.callId,
|
|
||||||
message: {
|
|
||||||
role: 'user',
|
|
||||||
content: [block],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.emitMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitSystemMessage(subtype: string, data?: unknown): void {
|
|
||||||
const systemMessage = {
|
|
||||||
type: 'system',
|
|
||||||
subtype,
|
|
||||||
uuid: randomUUID(),
|
|
||||||
session_id: this.getSessionId(),
|
|
||||||
data,
|
|
||||||
} as const;
|
|
||||||
this.emitMessage(systemMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSessionId(): string {
|
|
||||||
return this.config.getSessionId();
|
|
||||||
}
|
|
||||||
|
|
||||||
getModel(): string {
|
|
||||||
return this.config.getModel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractResponseText(): string {
|
|
||||||
const assistantMessages = this.messages.filter(
|
|
||||||
(msg): msg is CLIAssistantMessage =>
|
|
||||||
typeof msg === 'object' &&
|
|
||||||
msg !== null &&
|
|
||||||
'type' in msg &&
|
|
||||||
msg.type === 'assistant',
|
|
||||||
);
|
|
||||||
|
|
||||||
return assistantMessages
|
|
||||||
.map((msg) => extractTextFromBlocks(msg.message.content))
|
|
||||||
.filter((text) => text.length > 0)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guarantees that a single assistant message aggregates only one
|
|
||||||
* content block category (text, thinking, or tool use). When a new
|
|
||||||
* block type is requested, the current message is finalized and a fresh
|
|
||||||
* assistant message is started to honour the single-type constraint.
|
|
||||||
*/
|
|
||||||
private ensureBlockTypeConsistency(targetType: ContentBlock['type']): void {
|
|
||||||
if (this.currentBlockType === targetType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.currentBlockType === null) {
|
|
||||||
this.currentBlockType = targetType;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.finalizeAssistantMessage();
|
|
||||||
this.startAssistantMessage();
|
|
||||||
this.currentBlockType = targetType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function partsToString(parts: Part[]): string {
|
|
||||||
return parts
|
|
||||||
.map((part) => {
|
|
||||||
if ('text' in part && typeof part.text === 'string') {
|
|
||||||
return part.text;
|
|
||||||
}
|
|
||||||
return JSON.stringify(part);
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return partsToString(response.responseParts);
|
|
||||||
}
|
|
||||||
if (response.error) {
|
|
||||||
return response.error.message;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextFromBlocks(blocks: ContentBlock[]): string {
|
|
||||||
return blocks
|
|
||||||
.filter((block) => block.type === 'text')
|
|
||||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function createExtendedUsage(): ExtendedUsage {
|
|
||||||
return {
|
|
||||||
input_tokens: 0,
|
|
||||||
output_tokens: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -498,7 +498,9 @@ describe('StreamJsonOutputAdapter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const message = adapter.finalizeAssistantMessage();
|
const message = adapter.finalizeAssistantMessage();
|
||||||
expect(adapter.lastAssistantMessage).toEqual(message);
|
// 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', () => {
|
it('should return same message on subsequent calls', () => {
|
||||||
@@ -720,12 +722,13 @@ describe('StreamJsonOutputAdapter', () => {
|
|||||||
|
|
||||||
it('should handle parent_tool_use_id', () => {
|
it('should handle parent_tool_use_id', () => {
|
||||||
const parts: Part[] = [{ text: 'Tool response' }];
|
const parts: Part[] = [{ text: 'Tool response' }];
|
||||||
adapter.emitUserMessage(parts, 'tool-id-1');
|
adapter.emitUserMessage(parts);
|
||||||
|
|
||||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||||
const parsed = JSON.parse(output);
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
expect(parsed.parent_tool_use_id).toBe('tool-id-1');
|
// emitUserMessage currently sets parent_tool_use_id to null
|
||||||
|
expect(parsed.parent_tool_use_id).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -758,7 +761,7 @@ describe('StreamJsonOutputAdapter', () => {
|
|||||||
const parsed = JSON.parse(output);
|
const parsed = JSON.parse(output);
|
||||||
|
|
||||||
expect(parsed.type).toBe('user');
|
expect(parsed.type).toBe('user');
|
||||||
expect(parsed.parent_tool_use_id).toBe('tool-1');
|
expect(parsed.parent_tool_use_id).toBeNull();
|
||||||
const block = parsed.message.content[0];
|
const block = parsed.message.content[0];
|
||||||
expect(block).toMatchObject({
|
expect(block).toMatchObject({
|
||||||
type: 'tool_result',
|
type: 'tool_result',
|
||||||
|
|||||||
@@ -5,392 +5,44 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type {
|
import type { Config } from '@qwen-code/qwen-code-core';
|
||||||
Config,
|
|
||||||
ServerGeminiStreamEvent,
|
|
||||||
ToolCallRequestInfo,
|
|
||||||
ToolCallResponseInfo,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
|
||||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
|
||||||
import type {
|
import type {
|
||||||
CLIAssistantMessage,
|
CLIAssistantMessage,
|
||||||
|
CLIMessage,
|
||||||
CLIPartialAssistantMessage,
|
CLIPartialAssistantMessage,
|
||||||
CLIResultMessage,
|
ControlMessage,
|
||||||
CLIResultMessageError,
|
|
||||||
CLIResultMessageSuccess,
|
|
||||||
CLIUserMessage,
|
|
||||||
ContentBlock,
|
|
||||||
ExtendedUsage,
|
|
||||||
StreamEvent,
|
StreamEvent,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
ToolResultBlock,
|
|
||||||
ToolUseBlock,
|
ToolUseBlock,
|
||||||
Usage,
|
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import type {
|
import {
|
||||||
JsonOutputAdapterInterface,
|
BaseJsonOutputAdapter,
|
||||||
ResultOptions,
|
type MessageState,
|
||||||
} from './JsonOutputAdapter.js';
|
type ResultOptions,
|
||||||
|
type JsonOutputAdapterInterface,
|
||||||
|
} from './BaseJsonOutputAdapter.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream JSON output adapter that emits messages immediately
|
* Stream JSON output adapter that emits messages immediately
|
||||||
* as they are completed during the streaming process.
|
* as they are completed during the streaming process.
|
||||||
|
* Supports both main agent and subagent messages through distinct APIs.
|
||||||
*/
|
*/
|
||||||
export class StreamJsonOutputAdapter implements JsonOutputAdapterInterface {
|
export class StreamJsonOutputAdapter
|
||||||
lastAssistantMessage: CLIAssistantMessage | null = null;
|
extends BaseJsonOutputAdapter
|
||||||
|
implements JsonOutputAdapterInterface
|
||||||
// Assistant message building state
|
{
|
||||||
private messageId: string | null = null;
|
|
||||||
private blocks: ContentBlock[] = [];
|
|
||||||
private openBlocks = new Set<number>();
|
|
||||||
private usage: Usage = this.createUsage();
|
|
||||||
private messageStarted = false;
|
|
||||||
private finalized = false;
|
|
||||||
private currentBlockType: ContentBlock['type'] | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
config: Config,
|
||||||
private readonly includePartialMessages: boolean,
|
private readonly includePartialMessages: boolean,
|
||||||
) {}
|
) {
|
||||||
|
super(config);
|
||||||
private createUsage(
|
|
||||||
metadata?: GenerateContentResponseUsageMetadata | null,
|
|
||||||
): Usage {
|
|
||||||
const usage: Usage = {
|
|
||||||
input_tokens: 0,
|
|
||||||
output_tokens: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!metadata) {
|
|
||||||
return usage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof metadata.promptTokenCount === 'number') {
|
/**
|
||||||
usage.input_tokens = metadata.promptTokenCount;
|
* Emits message immediately to stdout (stream mode).
|
||||||
}
|
*/
|
||||||
if (typeof metadata.candidatesTokenCount === 'number') {
|
protected emitMessageImpl(message: CLIMessage | ControlMessage): void {
|
||||||
usage.output_tokens = metadata.candidatesTokenCount;
|
|
||||||
}
|
|
||||||
if (typeof metadata.cachedContentTokenCount === 'number') {
|
|
||||||
usage.cache_read_input_tokens = metadata.cachedContentTokenCount;
|
|
||||||
}
|
|
||||||
if (typeof metadata.totalTokenCount === 'number') {
|
|
||||||
usage.total_tokens = metadata.totalTokenCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return usage;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildMessage(): CLIAssistantMessage {
|
|
||||||
if (!this.messageId) {
|
|
||||||
throw new Error('Message not started');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce constraint: assistant message must contain only a single type of ContentBlock
|
|
||||||
if (this.blocks.length > 0) {
|
|
||||||
const blockTypes = new Set(this.blocks.map((block) => block.type));
|
|
||||||
if (blockTypes.size > 1) {
|
|
||||||
throw new Error(
|
|
||||||
`Assistant message must contain only one type of ContentBlock, found: ${Array.from(blockTypes).join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine stop_reason based on content block types
|
|
||||||
// If the message contains only tool_use blocks, set stop_reason to 'tool_use'
|
|
||||||
const stopReason =
|
|
||||||
this.blocks.length > 0 &&
|
|
||||||
this.blocks.every((block) => block.type === 'tool_use')
|
|
||||||
? 'tool_use'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'assistant',
|
|
||||||
uuid: this.messageId,
|
|
||||||
session_id: this.config.getSessionId(),
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
message: {
|
|
||||||
id: this.messageId,
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
model: this.config.getModel(),
|
|
||||||
content: this.blocks,
|
|
||||||
stop_reason: stopReason,
|
|
||||||
usage: this.usage,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendText(fragment: string): void {
|
|
||||||
if (fragment.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ensureBlockTypeConsistency('text');
|
|
||||||
this.ensureMessageStarted();
|
|
||||||
|
|
||||||
let current = this.blocks[this.blocks.length - 1];
|
|
||||||
if (!current || current.type !== 'text') {
|
|
||||||
current = { type: 'text', text: '' } satisfies TextBlock;
|
|
||||||
const index = this.blocks.length;
|
|
||||||
this.blocks.push(current);
|
|
||||||
this.openBlock(index, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
current.text += fragment;
|
|
||||||
const index = this.blocks.length - 1;
|
|
||||||
this.emitStreamEvent({
|
|
||||||
type: 'content_block_delta',
|
|
||||||
index,
|
|
||||||
delta: { type: 'text_delta', text: fragment },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendThinking(subject?: string, description?: string): void {
|
|
||||||
const fragment = [subject?.trim(), description?.trim()]
|
|
||||||
.filter((value) => value && value.length > 0)
|
|
||||||
.join(': ');
|
|
||||||
if (!fragment) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ensureBlockTypeConsistency('thinking');
|
|
||||||
this.ensureMessageStarted();
|
|
||||||
|
|
||||||
let current = this.blocks[this.blocks.length - 1];
|
|
||||||
if (!current || current.type !== 'thinking') {
|
|
||||||
current = {
|
|
||||||
type: 'thinking',
|
|
||||||
thinking: '',
|
|
||||||
signature: subject,
|
|
||||||
} satisfies ThinkingBlock;
|
|
||||||
const index = this.blocks.length;
|
|
||||||
this.blocks.push(current);
|
|
||||||
this.openBlock(index, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
current.thinking = `${current.thinking ?? ''}${fragment}`;
|
|
||||||
const index = this.blocks.length - 1;
|
|
||||||
this.emitStreamEvent({
|
|
||||||
type: 'content_block_delta',
|
|
||||||
index,
|
|
||||||
delta: { type: 'thinking_delta', thinking: fragment },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private appendToolUse(request: ToolCallRequestInfo): void {
|
|
||||||
this.ensureBlockTypeConsistency('tool_use');
|
|
||||||
this.ensureMessageStarted();
|
|
||||||
this.finalizePendingBlocks();
|
|
||||||
|
|
||||||
const index = this.blocks.length;
|
|
||||||
const block: ToolUseBlock = {
|
|
||||||
type: 'tool_use',
|
|
||||||
id: request.callId,
|
|
||||||
name: request.name,
|
|
||||||
input: request.args,
|
|
||||||
};
|
|
||||||
this.blocks.push(block);
|
|
||||||
this.openBlock(index, block);
|
|
||||||
this.emitStreamEvent({
|
|
||||||
type: 'content_block_delta',
|
|
||||||
index,
|
|
||||||
delta: {
|
|
||||||
type: 'input_json_delta',
|
|
||||||
partial_json: JSON.stringify(request.args ?? {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.closeBlock(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureMessageStarted(): void {
|
|
||||||
if (this.messageStarted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.messageStarted = true;
|
|
||||||
this.emitStreamEvent({
|
|
||||||
type: 'message_start',
|
|
||||||
message: {
|
|
||||||
id: this.messageId!,
|
|
||||||
role: 'assistant',
|
|
||||||
model: this.config.getModel(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private finalizePendingBlocks(): void {
|
|
||||||
const lastBlock = this.blocks[this.blocks.length - 1];
|
|
||||||
if (!lastBlock) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastBlock.type === 'text') {
|
|
||||||
const index = this.blocks.length - 1;
|
|
||||||
this.closeBlock(index);
|
|
||||||
} else if (lastBlock.type === 'thinking') {
|
|
||||||
const index = this.blocks.length - 1;
|
|
||||||
this.closeBlock(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private openBlock(index: number, block: ContentBlock): void {
|
|
||||||
this.openBlocks.add(index);
|
|
||||||
this.emitStreamEvent({
|
|
||||||
type: 'content_block_start',
|
|
||||||
index,
|
|
||||||
content_block: block,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private closeBlock(index: number): void {
|
|
||||||
if (!this.openBlocks.has(index)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.openBlocks.delete(index);
|
|
||||||
this.emitStreamEvent({
|
|
||||||
type: 'content_block_stop',
|
|
||||||
index,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitStreamEvent(event: StreamEvent): void {
|
|
||||||
if (!this.includePartialMessages) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const enrichedEvent = this.messageStarted
|
|
||||||
? ({ ...event, message_id: this.messageId } as StreamEvent & {
|
|
||||||
message_id: string;
|
|
||||||
})
|
|
||||||
: event;
|
|
||||||
const partial: CLIPartialAssistantMessage = {
|
|
||||||
type: 'stream_event',
|
|
||||||
uuid: randomUUID(),
|
|
||||||
session_id: this.config.getSessionId(),
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
event: enrichedEvent,
|
|
||||||
};
|
|
||||||
this.emitMessage(partial);
|
|
||||||
}
|
|
||||||
|
|
||||||
startAssistantMessage(): void {
|
|
||||||
// Reset state for new message
|
|
||||||
this.messageId = randomUUID();
|
|
||||||
this.blocks = [];
|
|
||||||
this.openBlocks = new Set<number>();
|
|
||||||
this.usage = this.createUsage();
|
|
||||||
this.messageStarted = false;
|
|
||||||
this.finalized = false;
|
|
||||||
this.currentBlockType = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
processEvent(event: ServerGeminiStreamEvent): void {
|
|
||||||
if (this.finalized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.type) {
|
|
||||||
case GeminiEventType.Content:
|
|
||||||
this.appendText(event.value);
|
|
||||||
break;
|
|
||||||
case GeminiEventType.Citation:
|
|
||||||
if (typeof event.value === 'string') {
|
|
||||||
this.appendText(`\n${event.value}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case GeminiEventType.Thought:
|
|
||||||
this.appendThinking(event.value.subject, event.value.description);
|
|
||||||
break;
|
|
||||||
case GeminiEventType.ToolCallRequest:
|
|
||||||
this.appendToolUse(event.value);
|
|
||||||
break;
|
|
||||||
case GeminiEventType.Finished:
|
|
||||||
if (event.value?.usageMetadata) {
|
|
||||||
this.usage = this.createUsage(event.value.usageMetadata);
|
|
||||||
}
|
|
||||||
this.finalizePendingBlocks();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
|
||||||
if (this.finalized) {
|
|
||||||
return this.buildMessage();
|
|
||||||
}
|
|
||||||
this.finalized = true;
|
|
||||||
|
|
||||||
this.finalizePendingBlocks();
|
|
||||||
const orderedOpenBlocks = Array.from(this.openBlocks).sort((a, b) => a - b);
|
|
||||||
for (const index of orderedOpenBlocks) {
|
|
||||||
this.closeBlock(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.messageStarted && this.includePartialMessages) {
|
|
||||||
this.emitStreamEvent({ type: 'message_stop' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = this.buildMessage();
|
|
||||||
this.lastAssistantMessage = message;
|
|
||||||
this.emitMessage(message);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
emitResult(options: ResultOptions): void {
|
|
||||||
const baseUuid = randomUUID();
|
|
||||||
const baseSessionId = this.getSessionId();
|
|
||||||
const usage = options.usage ?? createExtendedUsage();
|
|
||||||
const resultText =
|
|
||||||
options.summary ??
|
|
||||||
(this.lastAssistantMessage
|
|
||||||
? extractTextFromBlocks(this.lastAssistantMessage.message.content)
|
|
||||||
: '');
|
|
||||||
|
|
||||||
let message: CLIResultMessage;
|
|
||||||
if (options.isError) {
|
|
||||||
const errorMessage = options.errorMessage ?? 'Unknown error';
|
|
||||||
const errorResult: CLIResultMessageError = {
|
|
||||||
type: 'result',
|
|
||||||
subtype:
|
|
||||||
(options.subtype as CLIResultMessageError['subtype']) ??
|
|
||||||
'error_during_execution',
|
|
||||||
uuid: baseUuid,
|
|
||||||
session_id: baseSessionId,
|
|
||||||
is_error: true,
|
|
||||||
duration_ms: options.durationMs,
|
|
||||||
duration_api_ms: options.apiDurationMs,
|
|
||||||
num_turns: options.numTurns,
|
|
||||||
total_cost_usd: options.totalCostUsd ?? 0,
|
|
||||||
usage,
|
|
||||||
permission_denials: [],
|
|
||||||
error: { message: errorMessage },
|
|
||||||
};
|
|
||||||
message = errorResult;
|
|
||||||
} else {
|
|
||||||
const success: CLIResultMessageSuccess = {
|
|
||||||
type: 'result',
|
|
||||||
subtype:
|
|
||||||
(options.subtype as CLIResultMessageSuccess['subtype']) ?? 'success',
|
|
||||||
uuid: baseUuid,
|
|
||||||
session_id: baseSessionId,
|
|
||||||
is_error: false,
|
|
||||||
duration_ms: options.durationMs,
|
|
||||||
duration_api_ms: options.apiDurationMs,
|
|
||||||
num_turns: options.numTurns,
|
|
||||||
result: resultText,
|
|
||||||
total_cost_usd: options.totalCostUsd ?? 0,
|
|
||||||
usage,
|
|
||||||
permission_denials: [],
|
|
||||||
};
|
|
||||||
message = success;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emitMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitMessage(message: unknown): void {
|
|
||||||
// Track assistant messages for result generation
|
// Track assistant messages for result generation
|
||||||
if (
|
if (
|
||||||
typeof message === 'object' &&
|
typeof message === 'object' &&
|
||||||
@@ -398,138 +50,251 @@ export class StreamJsonOutputAdapter implements JsonOutputAdapterInterface {
|
|||||||
'type' in message &&
|
'type' in message &&
|
||||||
message.type === 'assistant'
|
message.type === 'assistant'
|
||||||
) {
|
) {
|
||||||
this.lastAssistantMessage = message as CLIAssistantMessage;
|
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit messages immediately in stream mode
|
// Emit messages immediately in stream mode
|
||||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitUserMessage(parts: Part[], parentToolUseId: string | null = null): void {
|
/**
|
||||||
const content = partsToString(parts);
|
* Stream mode emits stream events when includePartialMessages is enabled.
|
||||||
const message: CLIUserMessage = {
|
*/
|
||||||
type: 'user',
|
protected shouldEmitStreamEvents(): boolean {
|
||||||
uuid: randomUUID(),
|
return this.includePartialMessages;
|
||||||
session_id: this.getSessionId(),
|
|
||||||
parent_tool_use_id: parentToolUseId,
|
|
||||||
message: {
|
|
||||||
role: 'user',
|
|
||||||
content,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.emitMessage(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitToolResult(
|
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||||
request: ToolCallRequestInfo,
|
const state = this.mainAgentMessageState;
|
||||||
response: ToolCallResponseInfo,
|
if (state.finalized) {
|
||||||
): void {
|
return this.buildMessage(null);
|
||||||
const block: ToolResultBlock = {
|
}
|
||||||
type: 'tool_result',
|
state.finalized = true;
|
||||||
tool_use_id: request.callId,
|
|
||||||
is_error: Boolean(response.error),
|
this.finalizePendingBlocks(state, null);
|
||||||
};
|
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
|
||||||
const content = toolResultContent(response);
|
(a, b) => a - b,
|
||||||
if (content !== undefined) {
|
);
|
||||||
block.content = content;
|
for (const index of orderedOpenBlocks) {
|
||||||
|
this.onBlockClosed(state, index, null);
|
||||||
|
this.closeBlock(state, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
const message: CLIUserMessage = {
|
if (state.messageStarted && this.includePartialMessages) {
|
||||||
type: 'user',
|
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
|
||||||
uuid: randomUUID(),
|
|
||||||
session_id: this.getSessionId(),
|
|
||||||
parent_tool_use_id: request.callId,
|
|
||||||
message: {
|
|
||||||
role: 'user',
|
|
||||||
content: [block],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.emitMessage(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitSystemMessage(subtype: string, data?: unknown): void {
|
const message = this.buildMessage(null);
|
||||||
const systemMessage = {
|
this.updateLastAssistantMessage(message);
|
||||||
type: 'system',
|
this.emitMessageImpl(message);
|
||||||
subtype,
|
return message;
|
||||||
uuid: randomUUID(),
|
|
||||||
session_id: this.getSessionId(),
|
|
||||||
data,
|
|
||||||
} as const;
|
|
||||||
this.emitMessage(systemMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSessionId(): string {
|
emitResult(options: ResultOptions): void {
|
||||||
return this.config.getSessionId();
|
const resultMessage = this.buildResultMessage(
|
||||||
|
options,
|
||||||
|
this.lastAssistantMessage,
|
||||||
|
);
|
||||||
|
this.emitMessageImpl(resultMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
getModel(): string {
|
emitMessage(message: CLIMessage | ControlMessage): void {
|
||||||
return this.config.getModel();
|
// In stream mode, emit immediately
|
||||||
|
this.emitMessageImpl(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy methods for backward compatibility
|
send(message: CLIMessage | ControlMessage): void {
|
||||||
send(message: unknown): void {
|
|
||||||
this.emitMessage(message);
|
this.emitMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the assistant message scoped to a single content block type.
|
* Overrides base class hook to emit stream event when text block is created.
|
||||||
* If the requested block type differs from the current message type,
|
|
||||||
* the existing message is finalized and a fresh assistant message is started
|
|
||||||
* so that every emitted assistant message contains exactly one block category.
|
|
||||||
*/
|
*/
|
||||||
private ensureBlockTypeConsistency(targetType: ContentBlock['type']): void {
|
protected override onTextBlockCreated(
|
||||||
if (this.currentBlockType === targetType) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentBlockType === null) {
|
const state = this.getMessageState(parentToolUseId);
|
||||||
this.currentBlockType = targetType;
|
const enrichedEvent = state.messageStarted
|
||||||
return;
|
? ({ ...event, message_id: state.messageId } as StreamEvent & {
|
||||||
}
|
message_id: string;
|
||||||
|
|
||||||
this.finalizeAssistantMessage();
|
|
||||||
this.startAssistantMessage();
|
|
||||||
this.currentBlockType = targetType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function partsToString(parts: Part[]): string {
|
|
||||||
return parts
|
|
||||||
.map((part) => {
|
|
||||||
if ('text' in part && typeof part.text === 'string') {
|
|
||||||
return part.text;
|
|
||||||
}
|
|
||||||
return JSON.stringify(part);
|
|
||||||
})
|
})
|
||||||
.join('');
|
: event;
|
||||||
}
|
|
||||||
|
|
||||||
function toolResultContent(response: ToolCallResponseInfo): string | undefined {
|
const partial: CLIPartialAssistantMessage = {
|
||||||
if (
|
type: 'stream_event',
|
||||||
typeof response.resultDisplay === 'string' &&
|
uuid: randomUUID(),
|
||||||
response.resultDisplay.trim().length > 0
|
session_id: this.getSessionId(),
|
||||||
) {
|
parent_tool_use_id: parentToolUseId,
|
||||||
return response.resultDisplay;
|
event: enrichedEvent,
|
||||||
}
|
|
||||||
if (response.responseParts && response.responseParts.length > 0) {
|
|
||||||
return partsToString(response.responseParts);
|
|
||||||
}
|
|
||||||
if (response.error) {
|
|
||||||
return response.error.message;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTextFromBlocks(blocks: ContentBlock[]): string {
|
|
||||||
return blocks
|
|
||||||
.filter((block) => block.type === 'text')
|
|
||||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function createExtendedUsage(): ExtendedUsage {
|
|
||||||
return {
|
|
||||||
input_tokens: 0,
|
|
||||||
output_tokens: 0,
|
|
||||||
};
|
};
|
||||||
|
this.emitMessageImpl(partial);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ describe('runNonInteractive', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
expect.objectContaining({ name: 'testTool' }),
|
expect.objectContaining({ name: 'testTool' }),
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
@@ -302,6 +303,9 @@ describe('runNonInteractive', () => {
|
|||||||
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
|
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
|
||||||
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
|
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
|
||||||
|
|
||||||
|
// Enable debug mode so handleToolError logs to console.error
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
||||||
|
|
||||||
await runNonInteractive(
|
await runNonInteractive(
|
||||||
mockConfig,
|
mockConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -379,6 +383,9 @@ describe('runNonInteractive', () => {
|
|||||||
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
|
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
|
||||||
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
|
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
|
||||||
|
|
||||||
|
// Enable debug mode so handleToolError logs to console.error
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
||||||
|
|
||||||
await runNonInteractive(
|
await runNonInteractive(
|
||||||
mockConfig,
|
mockConfig,
|
||||||
mockSettings,
|
mockSettings,
|
||||||
@@ -608,6 +615,7 @@ describe('runNonInteractive', () => {
|
|||||||
mockConfig,
|
mockConfig,
|
||||||
expect.objectContaining({ name: 'testTool' }),
|
expect.objectContaining({ name: 'testTool' }),
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// JSON adapter emits array of messages, last one is result with stats
|
// JSON adapter emits array of messages, last one is result with stats
|
||||||
@@ -1211,7 +1219,14 @@ describe('runNonInteractive', () => {
|
|||||||
prompt_id: 'prompt-id-tool',
|
prompt_id: 'prompt-id-tool',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const toolResponse: Part[] = [{ text: 'Tool executed successfully' }];
|
const toolResponse: Part[] = [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: 'testTool',
|
||||||
|
response: { output: 'Tool executed successfully' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse });
|
mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse });
|
||||||
|
|
||||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||||
@@ -1279,7 +1294,7 @@ describe('runNonInteractive', () => {
|
|||||||
expect(toolResultBlock?.content).toBe('Tool executed successfully');
|
expect(toolResultBlock?.content).toBe('Tool executed successfully');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit system messages for tool errors in stream-json format', async () => {
|
it('should emit tool errors in tool_result blocks in stream-json format', async () => {
|
||||||
(mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json');
|
(mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json');
|
||||||
(mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false);
|
(mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false);
|
||||||
|
|
||||||
@@ -1346,14 +1361,30 @@ describe('runNonInteractive', () => {
|
|||||||
.filter((line) => line.trim().length > 0)
|
.filter((line) => line.trim().length > 0)
|
||||||
.map((line) => JSON.parse(line));
|
.map((line) => JSON.parse(line));
|
||||||
|
|
||||||
// Should have system message for tool error
|
// Tool errors are now captured in tool_result blocks with is_error=true,
|
||||||
const systemMessages = envelopes.filter((env) => env.type === 'system');
|
// not as separate system messages (see comment in nonInteractiveCli.ts line 307-309)
|
||||||
const toolErrorSystemMessage = systemMessages.find(
|
const toolResultMessages = envelopes.filter(
|
||||||
(msg) => msg.subtype === 'tool_error',
|
(env) =>
|
||||||
|
env.type === 'user' &&
|
||||||
|
Array.isArray(env.message?.content) &&
|
||||||
|
env.message.content.some(
|
||||||
|
(block: unknown) =>
|
||||||
|
typeof block === 'object' &&
|
||||||
|
block !== null &&
|
||||||
|
'type' in block &&
|
||||||
|
block.type === 'tool_result',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(toolErrorSystemMessage).toBeTruthy();
|
expect(toolResultMessages.length).toBeGreaterThan(0);
|
||||||
expect(toolErrorSystemMessage?.data?.tool).toBe('errorTool');
|
const toolResultBlock = toolResultMessages[0]?.message?.content?.find(
|
||||||
expect(toolErrorSystemMessage?.data?.message).toBe('Tool execution failed');
|
(block: unknown) =>
|
||||||
|
typeof block === 'object' &&
|
||||||
|
block !== null &&
|
||||||
|
'type' in block &&
|
||||||
|
block.type === 'tool_result',
|
||||||
|
);
|
||||||
|
expect(toolResultBlock?.tool_use_id).toBe('tool-error');
|
||||||
|
expect(toolResultBlock?.is_error).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit partial messages when includePartialMessages is true', async () => {
|
it('should emit partial messages when includePartialMessages is true', async () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from '@qwen-code/qwen-code-core';
|
} from '@qwen-code/qwen-code-core';
|
||||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||||
import type { JsonOutputAdapterInterface } from './nonInteractive/io/JsonOutputAdapter.js';
|
import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||||
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
|
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
|
||||||
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
|
||||||
import type { ControlService } from './nonInteractive/control/ControlService.js';
|
import type { ControlService } from './nonInteractive/control/ControlService.js';
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
extractUsageFromGeminiClient,
|
extractUsageFromGeminiClient,
|
||||||
calculateApproximateCost,
|
calculateApproximateCost,
|
||||||
buildSystemMessage,
|
buildSystemMessage,
|
||||||
|
createTaskToolProgressHandler,
|
||||||
} from './utils/nonInteractiveHelpers.js';
|
} from './utils/nonInteractiveHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,6 +218,7 @@ export async function runNonInteractive(
|
|||||||
|
|
||||||
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;
|
const finalRequestInfo = requestInfo;
|
||||||
|
|
||||||
@@ -254,17 +256,36 @@ export async function runNonInteractive(
|
|||||||
? options.controlService.permission.getToolCallUpdateCallback()
|
? options.controlService.permission.getToolCallUpdateCallback()
|
||||||
: undefined;
|
: 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,
|
||||||
finalRequestInfo,
|
finalRequestInfo,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
|
isTaskTool && taskToolProgressHandler
|
||||||
|
? {
|
||||||
|
outputUpdateHandler: taskToolProgressHandler,
|
||||||
/*
|
/*
|
||||||
toolCallUpdateCallback
|
toolCallUpdateCallback
|
||||||
? { onToolCallsUpdate: toolCallUpdateCallback }
|
? { onToolCallsUpdate: toolCallUpdateCallback }
|
||||||
: undefined,
|
: 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
|
// In JSON/STREAM_JSON mode, tool errors are tolerated and formatted
|
||||||
// as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode
|
// as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode
|
||||||
|
|||||||
@@ -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,6 +255,11 @@ describe('errors', () => {
|
|||||||
const toolName = 'test-tool';
|
const toolName = 'test-tool';
|
||||||
const toolError = new Error('Tool failed');
|
const toolError = new Error('Tool failed');
|
||||||
|
|
||||||
|
describe('when debug mode is enabled', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe('in text mode', () => {
|
describe('in text mode', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(
|
(
|
||||||
@@ -261,15 +267,16 @@ describe('errors', () => {
|
|||||||
).mockReturnValue(OutputFormat.TEXT);
|
).mockReturnValue(OutputFormat.TEXT);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log error message to stderr', () => {
|
it('should log error message to stderr and not exit', () => {
|
||||||
handleToolError(toolName, toolError, mockConfig);
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'Error executing tool test-tool: Tool failed',
|
'Error executing tool test-tool: Tool failed',
|
||||||
);
|
);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use resultDisplay when provided', () => {
|
it('should use resultDisplay when provided and not exit', () => {
|
||||||
handleToolError(
|
handleToolError(
|
||||||
toolName,
|
toolName,
|
||||||
toolError,
|
toolError,
|
||||||
@@ -281,6 +288,7 @@ describe('errors', () => {
|
|||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'Error executing tool test-tool: Custom display message',
|
'Error executing tool test-tool: Custom display message',
|
||||||
);
|
);
|
||||||
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,7 +302,7 @@ describe('errors', () => {
|
|||||||
it('should log error message to stderr and not exit', () => {
|
it('should log error message to stderr and not exit', () => {
|
||||||
handleToolError(toolName, toolError, mockConfig);
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
// In JSON mode, should not exit (just log to stderr)
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'Error executing tool test-tool: Tool failed',
|
'Error executing tool test-tool: Tool failed',
|
||||||
);
|
);
|
||||||
@@ -304,7 +312,7 @@ describe('errors', () => {
|
|||||||
it('should log error with custom error code and not exit', () => {
|
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');
|
||||||
|
|
||||||
// In JSON mode, should not exit (just log to stderr)
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'Error executing tool test-tool: Tool failed',
|
'Error executing tool test-tool: Tool failed',
|
||||||
);
|
);
|
||||||
@@ -314,14 +322,14 @@ describe('errors', () => {
|
|||||||
it('should log error with numeric error code and not exit', () => {
|
it('should log error with numeric error code and not exit', () => {
|
||||||
handleToolError(toolName, toolError, mockConfig, 500);
|
handleToolError(toolName, toolError, mockConfig, 500);
|
||||||
|
|
||||||
// In JSON mode, should not exit (just log to stderr)
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'Error executing tool test-tool: Tool failed',
|
'Error executing tool test-tool: Tool failed',
|
||||||
);
|
);
|
||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prefer resultDisplay over error message', () => {
|
it('should prefer resultDisplay over error message and not exit', () => {
|
||||||
handleToolError(
|
handleToolError(
|
||||||
toolName,
|
toolName,
|
||||||
toolError,
|
toolError,
|
||||||
@@ -330,34 +338,97 @@ describe('errors', () => {
|
|||||||
'Display message',
|
'Display message',
|
||||||
);
|
);
|
||||||
|
|
||||||
// In JSON mode, should not exit (just log to stderr)
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'Error executing tool test-tool: Display message',
|
'Error executing tool test-tool: Display message',
|
||||||
);
|
);
|
||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not exit in JSON mode', () => {
|
describe('in STREAM_JSON mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(
|
||||||
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
|
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log error message to stderr and not exit', () => {
|
||||||
handleToolError(toolName, toolError, mockConfig);
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
// Should not throw (no exit)
|
// Should not exit in STREAM_JSON mode (just log to stderr when debug mode is on)
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
'Error executing tool test-tool: Tool failed',
|
'Error executing tool test-tool: Tool failed',
|
||||||
);
|
);
|
||||||
expect(processExitSpy).not.toHaveBeenCalled();
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not exit in STREAM_JSON mode', () => {
|
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>
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||||
).mockReturnValue(OutputFormat.STREAM_JSON);
|
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||||
|
|
||||||
handleToolError(toolName, toolError, mockConfig);
|
handleToolError(toolName, toolError, mockConfig);
|
||||||
|
|
||||||
// Should not exit in STREAM_JSON mode
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
'Error executing tool test-tool: Tool failed',
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
|
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();
|
expect(processExitSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config } from '@qwen-code/qwen-code-core';
|
import type {
|
||||||
|
Config,
|
||||||
|
ToolResultDisplay,
|
||||||
|
TaskResultDisplay,
|
||||||
|
OutputUpdateHandler,
|
||||||
|
ToolCallRequestInfo,
|
||||||
|
ToolCallResponseInfo,
|
||||||
|
} from '@qwen-code/qwen-code-core';
|
||||||
|
import { ToolErrorType } from '@qwen-code/qwen-code-core';
|
||||||
import type { Part, PartListUnion } from '@google/genai';
|
import type { Part, PartListUnion } from '@google/genai';
|
||||||
import type {
|
import type {
|
||||||
CLIUserMessage,
|
CLIUserMessage,
|
||||||
@@ -15,6 +23,7 @@ import type {
|
|||||||
} from '../nonInteractive/types.js';
|
} from '../nonInteractive/types.js';
|
||||||
import { CommandService } from '../services/CommandService.js';
|
import { CommandService } from '../services/CommandService.js';
|
||||||
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
|
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
|
||||||
|
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes various part list formats into a consistent Part[] array.
|
* Normalizes various part list formats into a consistent Part[] array.
|
||||||
@@ -244,3 +253,324 @@ export async function buildSystemMessage(
|
|||||||
|
|
||||||
return systemMessage;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update previous state
|
||||||
|
previousTaskStates.set(callId, taskDisplay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,7 +73,7 @@ export interface SubAgentToolResultEvent {
|
|||||||
name: string;
|
name: string;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
resultDisplay?: string;
|
responseParts?: Part[];
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -619,11 +619,7 @@ export class SubAgentScope {
|
|||||||
name: toolName,
|
name: toolName,
|
||||||
success,
|
success,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
resultDisplay: call.response.resultDisplay
|
responseParts: call.response.responseParts,
|
||||||
? typeof call.response.resultDisplay === 'string'
|
|
||||||
? call.response.resultDisplay
|
|
||||||
: JSON.stringify(call.response.resultDisplay)
|
|
||||||
: undefined,
|
|
||||||
durationMs: duration,
|
durationMs: duration,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
} as SubAgentToolResultEvent);
|
} as SubAgentToolResultEvent);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FunctionDeclaration, PartListUnion } from '@google/genai';
|
import type { FunctionDeclaration, Part, PartListUnion } from '@google/genai';
|
||||||
import { ToolErrorType } from './tool-error.js';
|
import { ToolErrorType } from './tool-error.js';
|
||||||
import type { DiffUpdateResult } from '../ide/ide-client.js';
|
import type { DiffUpdateResult } from '../ide/ide-client.js';
|
||||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||||
@@ -461,6 +461,7 @@ export interface TaskResultDisplay {
|
|||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
result?: string;
|
result?: string;
|
||||||
resultDisplay?: string;
|
resultDisplay?: string;
|
||||||
|
responseParts?: Part[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user