mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-26 00:53:48 +00:00
* feat(mcp): add 2026 protocol negotiation * feat(mcp): render MCP Apps in WebShell * fix(mcp): keep legacy tool discovery lenient * fix(mcp): keep Apps HTML out of TUI and honor tool visibility TUI and history compaction dumped mcp_app HTML as JSON, and discoverTools registered app-only tools for the model. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): stabilize AppBridge lifetime and close sandbox CSP gaps Theme toggles and transcript reseeds were tearing down MCP Apps; the host CSP also allowed any loopback port and form posts bypassed connect-src. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): list under-declared modern MCP capabilities over the wire v2 typed helpers return [] without a request when a capability is omitted. Use them only when the server declared the capability, and keep Apps unmounted in collapsed tool rows. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): keep Apps sandbox reachable and list past 64 pages Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): reject empty compacted html and keep MCP Apps expanded in multi-tool groups Fixes R3-1 and R3-2 review comments: R3-1: getMcpAppDisplay now rejects empty html strings (from compaction) so session replay shows fallbackText instead of mounting an empty iframe. R3-2: ToolGroup now checks for MCP apps across all tools (not just singleTool), auto-expands when any tool has an MCP app, and keeps MCP app rows expanded (summaryOnly=false, forceExpanded=true) even when adjacent tool calls are merged into the group. * feat(web-shell): fold thinking into the compact-mode tool summary (#9148) Compact mode used to drop thinking messages entirely, so a running turn gave no indication of the thinking step. Keep the thoughts and aggregate them with the adjacent tools into one summary: a streaming thought reads "Thinking…" with the running shimmer, and a completed thought settles into a click-to-expand row in its original interleaved position. The translate action is preserved on both the thinking block and the folded thought rows, and the merged group gets a synthetic id so its expanded state never leaks into non-compact mode. Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com> * fix(mcp): address app discovery and sandbox regressions * fix(web-shell): keep MCP apps expanded in compact summaries * Revert "feat(web-shell): fold thinking into the compact-mode tool summary (#9148)" This reverts commit ab2eebc5d36f17a51ce94e423db5745dcbb273fe. * fix(web-shell): render compacted MCP App fallback and teardown before unload Compacted history keeps type:mcp_app with empty html; show fallbackText instead of a blank sandbox, and wait for ui/resource-teardown before unloading the iframe. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): bound the discover probe and raise the daemon bundle cap Silent legacy stdio servers inherited the 10-minute request timeout for server/discover. Cap the probe at 5s so fallback fits the discovery window, and raise the browser bundle budget after the main merge overflowed CI by 47 bytes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): skip version-negotiation probe on remote transports SDK v2 rejects HTTP server/discover timeouts without falling back to initialize, and the 5s probe consumed the entire remote discovery window. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web-shell): keep MCP App iframe src across deferred teardown Deferred unload() was clearing src on the live iframe after a remount, so the new AppBridge never saw sandbox-proxy-ready. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): honor listing-level MCP App CSP and permissions registerAppResource puts ui.csp/permissions on resources/list, and resources/read does not merge that metadata into content entries. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): reuse session client for list and emit app fallback text Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): keep mcp list and IPv6 sandbox CSP valid Give qwen mcp list leftover handshake budget after the 5s discover probe, and stop emitting invalid [::1] CSP origins. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): keep modern list and short discovery budgets working Drop the era-illegal ping after mcp list connect, shrink the stdio discover probe to the discovery window, and document that remotes stay on legacy initialize until the SDK can fall back. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): keep the 2026 slice free of review-only extras Drop the global tools/list page cap, generated companion notices, and the review screenshot so this PR stays on stdio 2026 plus the WebShell Apps host. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): restore generated companion notices after the SDK v2 bump CI regenerates NOTICES.txt from the lockfile; the file has to ship with the new MCP client dependencies. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): isolate the Apps proxy from WebShell storage Drop allow-same-origin on the outer sandbox iframe so a default localhost daemon cannot read the WebShell session token. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(mcp): harden fallback and app sandbox * fix(core): preserve large and app-only MCP catalogs * fix(mcp): preserve legacy negotiation compatibility * fix(mcp): default stdio negotiation to legacy --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: ytahdn <1294726970@qq.com> Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com> Co-authored-by: YungSen Hsin <yungsenhsin@U-G0HXNQM1-2052.local>
1515 lines
46 KiB
TypeScript
1515 lines
46 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
import type {
|
|
Config,
|
|
ToolCallRequestInfo,
|
|
ToolCallResponseInfo,
|
|
SessionMetrics,
|
|
ServerGeminiStreamEvent,
|
|
AgentResultDisplay,
|
|
McpToolProgressData,
|
|
ShellProgressData,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import {
|
|
formatVisionBridgeNoticeDisplay,
|
|
GeminiEventType,
|
|
isVisionBridgeNoticeDisplay,
|
|
ToolErrorType,
|
|
parseAndFormatApiError,
|
|
toolResultBoundaryArtifact,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
|
import type {
|
|
CLIAssistantMessage,
|
|
CLIMessage,
|
|
CLIPermissionDenial,
|
|
CLIResultMessage,
|
|
CLIResultMessageError,
|
|
CLIResultMessageSuccess,
|
|
CLIUserMessage,
|
|
ContentBlock,
|
|
ControlMessage,
|
|
ExtendedUsage,
|
|
PermissionSuggestion,
|
|
TextBlock,
|
|
ThinkingBlock,
|
|
ToolResultBlock,
|
|
ToolUseBlock,
|
|
Usage,
|
|
} from '../types.js';
|
|
import { functionResponsePartsToString } from '../nonInteractiveHelpers.js';
|
|
import { projectHeadlessToolResultContent } from './headless-tool-result-text-projection.js';
|
|
import { observeHeadlessToolResultProjection } from '../tool-result-boundary-diagnostics.js';
|
|
|
|
/**
|
|
* Internal state for managing a single message context (main agent or subagent).
|
|
*/
|
|
export interface MessageState {
|
|
messageId: string | null;
|
|
blocks: ContentBlock[];
|
|
openBlocks: Set<number>;
|
|
usage: Usage;
|
|
messageStarted: boolean;
|
|
finalized: boolean;
|
|
currentBlockType: ContentBlock['type'] | null;
|
|
}
|
|
|
|
/**
|
|
* Options for building result messages.
|
|
* Used by both streaming and non-streaming JSON output adapters.
|
|
*/
|
|
export interface ResultOptions {
|
|
readonly isError: boolean;
|
|
readonly errorMessage?: string;
|
|
readonly durationMs: number;
|
|
readonly apiDurationMs: number;
|
|
readonly numTurns: number;
|
|
readonly usage?: ExtendedUsage;
|
|
readonly stats?: SessionMetrics;
|
|
readonly summary?: string;
|
|
readonly subtype?: string;
|
|
/**
|
|
* Payload that the model submitted via the synthetic `structured_output`
|
|
* tool. When set, `result` is forced to the JSON-stringified form and a
|
|
* top-level `structured_result` field is added to the result message.
|
|
*/
|
|
readonly structuredResult?: unknown;
|
|
}
|
|
|
|
/**
|
|
* Interface for message emission strategies.
|
|
* Implementations decide whether to emit messages immediately (streaming)
|
|
* or collect them for batch emission (non-streaming).
|
|
* This interface defines the common message emission methods that
|
|
* all JSON output adapters should implement.
|
|
*/
|
|
export interface MessageEmitter {
|
|
emitMessage(message: CLIMessage): void;
|
|
emitUserMessage(parts: Part[], parentToolUseId?: string | null): void;
|
|
emitToolResult(
|
|
request: ToolCallRequestInfo,
|
|
response: ToolCallResponseInfo,
|
|
parentToolUseId?: string | null,
|
|
): void;
|
|
emitSystemMessage(subtype: string, data?: unknown): void;
|
|
/**
|
|
* Emits a tool progress stream event.
|
|
* Only emits when the adapter supports partial messages (stream mode).
|
|
* In non-streaming mode, this is a no-op.
|
|
*
|
|
* @param request - Tool call request info
|
|
* @param progress - Structured MCP progress data or shell liveness heartbeat
|
|
*/
|
|
emitToolProgress(
|
|
request: ToolCallRequestInfo,
|
|
progress: McpToolProgressData | ShellProgressData,
|
|
): void;
|
|
}
|
|
|
|
/**
|
|
* JSON-focused output adapter interface.
|
|
* Handles structured JSON output for both streaming and non-streaming modes.
|
|
* This interface defines the complete API that all JSON output adapters must implement.
|
|
*/
|
|
export interface JsonOutputAdapterInterface extends MessageEmitter {
|
|
startAssistantMessage(): void;
|
|
processEvent(event: ServerGeminiStreamEvent): void;
|
|
finalizeAssistantMessage(): CLIAssistantMessage;
|
|
emitResult(options: ResultOptions): void;
|
|
|
|
startSubagentAssistantMessage?(parentToolUseId: string): void;
|
|
processSubagentToolCall?(
|
|
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
|
parentToolUseId: string,
|
|
): void;
|
|
finalizeSubagentAssistantMessage?(
|
|
parentToolUseId: string,
|
|
): CLIAssistantMessage;
|
|
emitSubagentErrorResult?(
|
|
errorMessage: string,
|
|
numTurns: number,
|
|
parentToolUseId: string,
|
|
): void;
|
|
|
|
getSessionId(): string;
|
|
getModel(): string;
|
|
}
|
|
|
|
/**
|
|
* Abstract base class for JSON output adapters.
|
|
* Contains shared logic for message building, state management, and content block handling.
|
|
*/
|
|
export abstract class BaseJsonOutputAdapter {
|
|
protected readonly config: Config;
|
|
|
|
// Main agent message state
|
|
protected mainAgentMessageState: MessageState;
|
|
|
|
// Subagent message states keyed by parentToolUseId
|
|
protected subagentMessageStates = new Map<string, MessageState>();
|
|
|
|
// Last assistant message for result generation
|
|
protected lastAssistantMessage: CLIAssistantMessage | null = null;
|
|
|
|
// Track permission denials (execution denied tool calls)
|
|
protected permissionDenials: CLIPermissionDenial[] = [];
|
|
|
|
constructor(config: Config) {
|
|
this.config = config;
|
|
this.mainAgentMessageState = this.createMessageState();
|
|
}
|
|
|
|
/**
|
|
* Creates a new message state with default values.
|
|
*/
|
|
protected createMessageState(): MessageState {
|
|
return {
|
|
messageId: null,
|
|
blocks: [],
|
|
openBlocks: new Set<number>(),
|
|
usage: this.createUsage(),
|
|
messageStarted: false,
|
|
finalized: false,
|
|
currentBlockType: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gets or creates message state for a given context.
|
|
*
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
* @returns MessageState for the context
|
|
*/
|
|
protected getMessageState(parentToolUseId: string | null): MessageState {
|
|
if (parentToolUseId === null) {
|
|
return this.mainAgentMessageState;
|
|
}
|
|
|
|
let state = this.subagentMessageStates.get(parentToolUseId);
|
|
if (!state) {
|
|
state = this.createMessageState();
|
|
this.subagentMessageStates.set(parentToolUseId, state);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
/**
|
|
* Creates a Usage object from metadata.
|
|
*
|
|
* @param metadata - Optional usage metadata from Gemini API
|
|
* @returns Usage object
|
|
*/
|
|
protected 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;
|
|
}
|
|
if (typeof metadata.candidatesTokenCount === 'number') {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Builds a CLIAssistantMessage from the current message state.
|
|
*
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
* @returns CLIAssistantMessage
|
|
*/
|
|
protected buildMessage(parentToolUseId: string | null): CLIAssistantMessage {
|
|
const state = this.getMessageState(parentToolUseId);
|
|
|
|
if (!state.messageId) {
|
|
throw new Error('Message not started');
|
|
}
|
|
|
|
// Enforce constraint: assistant message must contain only a single type of ContentBlock
|
|
if (state.blocks.length > 0) {
|
|
const blockTypes = new Set(state.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 =
|
|
state.blocks.length > 0 &&
|
|
state.blocks.every((block) => block.type === 'tool_use')
|
|
? 'tool_use'
|
|
: null;
|
|
|
|
return {
|
|
type: 'assistant',
|
|
uuid: state.messageId,
|
|
session_id: this.config.getSessionId(),
|
|
parent_tool_use_id: parentToolUseId,
|
|
message: {
|
|
id: state.messageId,
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: this.config.getModel(),
|
|
content: state.blocks,
|
|
stop_reason: stopReason,
|
|
usage: state.usage,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Finalizes pending blocks (text or thinking) by closing them.
|
|
*
|
|
* @param state - Message state to finalize blocks for
|
|
* @param parentToolUseId - null for main agent, string for subagent (optional, defaults to null)
|
|
*/
|
|
protected finalizePendingBlocks(
|
|
state: MessageState,
|
|
parentToolUseId?: string | null,
|
|
): void {
|
|
const actualParentToolUseId = parentToolUseId ?? null;
|
|
const lastBlock = state.blocks[state.blocks.length - 1];
|
|
if (!lastBlock) {
|
|
return;
|
|
}
|
|
|
|
const index = state.blocks.length - 1;
|
|
if (!state.openBlocks.has(index)) {
|
|
return;
|
|
}
|
|
|
|
if (lastBlock.type === 'text' || lastBlock.type === 'thinking') {
|
|
this.onBlockClosed(state, index, actualParentToolUseId);
|
|
this.closeBlock(state, index);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens a block (adds to openBlocks set).
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param _block - Content block
|
|
*/
|
|
protected openBlock(
|
|
state: MessageState,
|
|
index: number,
|
|
_block: ContentBlock,
|
|
): void {
|
|
state.openBlocks.add(index);
|
|
}
|
|
|
|
/**
|
|
* Closes a block (removes from openBlocks set).
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
*/
|
|
protected closeBlock(state: MessageState, index: number): void {
|
|
if (!state.openBlocks.has(index)) {
|
|
return;
|
|
}
|
|
state.openBlocks.delete(index);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param state - Message state
|
|
* @param targetType - Target block type
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected ensureBlockTypeConsistency(
|
|
state: MessageState,
|
|
targetType: ContentBlock['type'],
|
|
parentToolUseId: string | null,
|
|
): void {
|
|
if (state.currentBlockType === targetType) {
|
|
return;
|
|
}
|
|
|
|
if (state.currentBlockType === null) {
|
|
state.currentBlockType = targetType;
|
|
return;
|
|
}
|
|
|
|
// Finalize current message and start new one
|
|
this.finalizeAssistantMessageInternal(state, parentToolUseId);
|
|
this.startAssistantMessageInternal(state);
|
|
state.currentBlockType = targetType;
|
|
}
|
|
|
|
/**
|
|
* Starts a new assistant message, resetting state.
|
|
*
|
|
* @param state - Message state to reset
|
|
*/
|
|
protected startAssistantMessageInternal(state: MessageState): void {
|
|
state.messageId = randomUUID();
|
|
state.blocks = [];
|
|
state.openBlocks = new Set<number>();
|
|
state.usage = this.createUsage();
|
|
state.messageStarted = false;
|
|
state.finalized = false;
|
|
state.currentBlockType = null;
|
|
}
|
|
|
|
/**
|
|
* Finalizes an assistant message.
|
|
*
|
|
* @param state - Message state to finalize
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
* @returns CLIAssistantMessage
|
|
*/
|
|
protected finalizeAssistantMessageInternal(
|
|
state: MessageState,
|
|
parentToolUseId: string | null,
|
|
): CLIAssistantMessage {
|
|
if (state.finalized) {
|
|
return this.buildMessage(parentToolUseId);
|
|
}
|
|
state.finalized = true;
|
|
|
|
this.finalizePendingBlocks(state, parentToolUseId);
|
|
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
|
|
(a, b) => a - b,
|
|
);
|
|
for (const index of orderedOpenBlocks) {
|
|
this.onBlockClosed(state, index, parentToolUseId);
|
|
this.closeBlock(state, index);
|
|
}
|
|
|
|
const message = this.buildMessage(parentToolUseId);
|
|
if (state.messageStarted) {
|
|
this.emitMessageImpl(message);
|
|
}
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* Abstract method for emitting messages. Implementations decide whether
|
|
* to emit immediately (streaming) or collect for batch emission.
|
|
* Note: The message object already contains parent_tool_use_id field,
|
|
* so it doesn't need to be passed as a separate parameter.
|
|
*
|
|
* @param message - Message to emit (already contains parent_tool_use_id if applicable)
|
|
*/
|
|
protected abstract emitMessageImpl(message: CLIMessage): void;
|
|
|
|
/**
|
|
* Emits a control-plane message (control_request / control_response).
|
|
* Only meaningful in streaming adapters; batch adapters inherit this
|
|
* no-op since control messages are not collected into the final array.
|
|
*/
|
|
protected emitControlMessageImpl(_message: ControlMessage): void {
|
|
// Default: no-op for non-streaming / batch adapters.
|
|
}
|
|
|
|
/**
|
|
* Abstract method to determine if stream events should be emitted.
|
|
*
|
|
* @returns true if stream events should be emitted
|
|
*/
|
|
protected abstract shouldEmitStreamEvents(): boolean;
|
|
|
|
/**
|
|
* Hook method called when a text block is created.
|
|
* Subclasses can override this to emit stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param block - Text block that was created
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected onTextBlockCreated(
|
|
_state: MessageState,
|
|
_index: number,
|
|
_block: TextBlock,
|
|
_parentToolUseId: string | null,
|
|
): void {
|
|
// Default implementation does nothing
|
|
}
|
|
|
|
/**
|
|
* Hook method called when text content is appended.
|
|
* Subclasses can override this to emit stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param fragment - Text fragment that was appended
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected onTextAppended(
|
|
_state: MessageState,
|
|
_index: number,
|
|
_fragment: string,
|
|
_parentToolUseId: string | null,
|
|
): void {
|
|
// Default implementation does nothing
|
|
}
|
|
|
|
/**
|
|
* Hook method called when a thinking block is created.
|
|
* Subclasses can override this to emit stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param block - Thinking block that was created
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected onThinkingBlockCreated(
|
|
_state: MessageState,
|
|
_index: number,
|
|
_block: ThinkingBlock,
|
|
_parentToolUseId: string | null,
|
|
): void {
|
|
// Default implementation does nothing
|
|
}
|
|
|
|
/**
|
|
* Hook method called when thinking content is appended.
|
|
* Subclasses can override this to emit stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param fragment - Thinking fragment that was appended
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected onThinkingAppended(
|
|
_state: MessageState,
|
|
_index: number,
|
|
_fragment: string,
|
|
_parentToolUseId: string | null,
|
|
): void {
|
|
// Default implementation does nothing
|
|
}
|
|
|
|
/**
|
|
* Hook method called when a tool_use block is created.
|
|
* Subclasses can override this to emit stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param block - Tool use block that was created
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected onToolUseBlockCreated(
|
|
_state: MessageState,
|
|
_index: number,
|
|
_block: ToolUseBlock,
|
|
_parentToolUseId: string | null,
|
|
): void {
|
|
// Default implementation does nothing
|
|
}
|
|
|
|
/**
|
|
* Hook method called when tool_use input is set.
|
|
* Subclasses can override this to emit stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param input - Tool use input that was set
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected onToolUseInputSet(
|
|
_state: MessageState,
|
|
_index: number,
|
|
_input: unknown,
|
|
_parentToolUseId: string | null,
|
|
): void {
|
|
// Default implementation does nothing
|
|
}
|
|
|
|
/**
|
|
* Hook method called when a block is closed.
|
|
* Subclasses can override this to emit stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected onBlockClosed(
|
|
_state: MessageState,
|
|
_index: number,
|
|
_parentToolUseId: string | null,
|
|
): void {
|
|
// Default implementation does nothing
|
|
}
|
|
|
|
/**
|
|
* Hook method called to ensure message is started.
|
|
* Subclasses can override this to emit message_start events.
|
|
*
|
|
* @param state - Message state
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected onEnsureMessageStarted(
|
|
_state: MessageState,
|
|
_parentToolUseId: string | null,
|
|
): void {
|
|
// Default implementation does nothing
|
|
}
|
|
|
|
/**
|
|
* Gets the session ID from config.
|
|
*
|
|
* @returns Session ID
|
|
*/
|
|
getSessionId(): string {
|
|
return this.config.getSessionId();
|
|
}
|
|
|
|
/**
|
|
* Gets the model name from config.
|
|
*
|
|
* @returns Model name
|
|
*/
|
|
getModel(): string {
|
|
return this.config.getModel();
|
|
}
|
|
|
|
// ========== Main Agent APIs ==========
|
|
|
|
/**
|
|
* Starts a new assistant message for the main agent.
|
|
* This is a shared implementation used by both streaming and non-streaming adapters.
|
|
*/
|
|
startAssistantMessage(): void {
|
|
this.startAssistantMessageInternal(this.mainAgentMessageState);
|
|
}
|
|
|
|
/**
|
|
* Processes a stream event from the Gemini API.
|
|
* This is a shared implementation used by both streaming and non-streaming adapters.
|
|
*
|
|
* @param event - Stream event from Gemini API
|
|
*/
|
|
processEvent(event: ServerGeminiStreamEvent): void {
|
|
const state = this.mainAgentMessageState;
|
|
if (state.finalized) {
|
|
return;
|
|
}
|
|
|
|
switch (event.type) {
|
|
case GeminiEventType.Content:
|
|
this.appendText(state, event.value, null);
|
|
break;
|
|
case GeminiEventType.Citation:
|
|
if (typeof event.value === 'string') {
|
|
this.appendText(state, `\n${event.value}`, null);
|
|
}
|
|
break;
|
|
case GeminiEventType.Thought:
|
|
this.appendThinking(
|
|
state,
|
|
event.value.subject,
|
|
event.value.description,
|
|
null,
|
|
);
|
|
break;
|
|
case GeminiEventType.ToolCallRequest:
|
|
this.appendToolUse(state, event.value, null);
|
|
break;
|
|
case GeminiEventType.Finished:
|
|
if (event.value?.usageMetadata) {
|
|
state.usage = this.createUsage(event.value.usageMetadata);
|
|
}
|
|
this.finalizePendingBlocks(state, null);
|
|
break;
|
|
case GeminiEventType.Error: {
|
|
// Format the error message using parseAndFormatApiError for consistency
|
|
// with interactive mode error display
|
|
const errorText = parseAndFormatApiError(
|
|
event.value.error,
|
|
this.config.getContentGeneratorConfig()?.authType,
|
|
);
|
|
this.appendText(state, errorText, null);
|
|
break;
|
|
}
|
|
case GeminiEventType.ModelFallback:
|
|
// Surface model fallback transitions so non-interactive consumers
|
|
// (CI pipelines, SDK clients) can observe capacity-driven model
|
|
// switches without parsing assistant content.
|
|
this.emitSystemMessage('model_fallback', {
|
|
fromModel: event.fromModel,
|
|
toModel: event.toModel,
|
|
statusCode: event.statusCode,
|
|
fallbackIndex: event.fallbackIndex,
|
|
});
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ========== Subagent APIs ==========
|
|
|
|
/**
|
|
* Starts a new assistant message for a subagent.
|
|
* This is a shared implementation used by both streaming and non-streaming adapters.
|
|
*
|
|
* @param parentToolUseId - Parent tool use ID
|
|
*/
|
|
startSubagentAssistantMessage(parentToolUseId: string): void {
|
|
const state = this.getMessageState(parentToolUseId);
|
|
this.startAssistantMessageInternal(state);
|
|
}
|
|
|
|
/**
|
|
* Finalizes a subagent assistant message.
|
|
* This is a shared implementation used by both streaming and non-streaming adapters.
|
|
*
|
|
* @param parentToolUseId - Parent tool use ID
|
|
* @returns CLIAssistantMessage
|
|
*/
|
|
finalizeSubagentAssistantMessage(
|
|
parentToolUseId: string,
|
|
): CLIAssistantMessage {
|
|
const state = this.getMessageState(parentToolUseId);
|
|
return this.finalizeAssistantMessageInternal(state, parentToolUseId);
|
|
}
|
|
|
|
/**
|
|
* Emits a subagent error result message.
|
|
* This is a shared implementation used by both streaming and non-streaming adapters.
|
|
*
|
|
* @param errorMessage - Error message
|
|
* @param numTurns - Number of turns
|
|
* @param parentToolUseId - Parent tool use ID
|
|
*/
|
|
emitSubagentErrorResult(
|
|
errorMessage: string,
|
|
numTurns: number,
|
|
parentToolUseId: string,
|
|
): void {
|
|
const state = this.getMessageState(parentToolUseId);
|
|
// Finalize any pending assistant message
|
|
if (state.messageStarted && !state.finalized) {
|
|
this.finalizeSubagentAssistantMessage(parentToolUseId);
|
|
}
|
|
|
|
const errorResult = this.buildSubagentErrorResult(errorMessage, numTurns);
|
|
this.emitMessageImpl(errorResult);
|
|
}
|
|
|
|
/**
|
|
* Processes a subagent tool call.
|
|
* This is a shared implementation used by both streaming and non-streaming adapters.
|
|
* Uses template method pattern with hooks for stream events.
|
|
*
|
|
* @param toolCall - Tool call information
|
|
* @param parentToolUseId - Parent tool use ID
|
|
*/
|
|
processSubagentToolCall(
|
|
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
|
parentToolUseId: string,
|
|
): void {
|
|
const state = this.getMessageState(parentToolUseId);
|
|
|
|
// Finalize any pending text message before starting tool_use
|
|
const hasText =
|
|
state.blocks.some((b) => b.type === 'text') ||
|
|
(state.currentBlockType === 'text' && state.blocks.length > 0);
|
|
if (hasText) {
|
|
this.finalizeSubagentAssistantMessage(parentToolUseId);
|
|
this.startSubagentAssistantMessage(parentToolUseId);
|
|
}
|
|
|
|
// Ensure message is started before appending tool_use
|
|
if (!state.messageId || !state.messageStarted) {
|
|
this.startAssistantMessageInternal(state);
|
|
}
|
|
|
|
this.ensureBlockTypeConsistency(state, 'tool_use', parentToolUseId);
|
|
this.ensureMessageStarted(state, parentToolUseId);
|
|
this.finalizePendingBlocks(state, parentToolUseId);
|
|
|
|
const { index } = this.createSubagentToolUseBlock(
|
|
state,
|
|
toolCall,
|
|
parentToolUseId,
|
|
);
|
|
|
|
// Process tool use block creation and closure
|
|
// Subclasses can override hook methods to emit stream events
|
|
this.processSubagentToolUseBlock(state, index, toolCall, parentToolUseId);
|
|
|
|
// Finalize tool_use message immediately
|
|
this.finalizeSubagentAssistantMessage(parentToolUseId);
|
|
this.startSubagentAssistantMessage(parentToolUseId);
|
|
}
|
|
|
|
/**
|
|
* Processes a tool use block for subagent.
|
|
* This method is called by processSubagentToolCall to handle tool use block creation,
|
|
* input setting, and closure. Subclasses can override this to customize behavior.
|
|
*
|
|
* @param state - Message state
|
|
* @param index - Block index
|
|
* @param toolCall - Tool call information
|
|
* @param parentToolUseId - Parent tool use ID
|
|
*/
|
|
protected processSubagentToolUseBlock(
|
|
state: MessageState,
|
|
index: number,
|
|
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
|
parentToolUseId: string,
|
|
): void {
|
|
// Emit tool_use block creation event (with empty input)
|
|
const startBlock: ToolUseBlock = {
|
|
type: 'tool_use',
|
|
id: toolCall.callId,
|
|
name: toolCall.name,
|
|
input: {},
|
|
};
|
|
this.onToolUseBlockCreated(state, index, startBlock, parentToolUseId);
|
|
this.onToolUseInputSet(state, index, toolCall.args ?? {}, parentToolUseId);
|
|
this.onBlockClosed(state, index, parentToolUseId);
|
|
this.closeBlock(state, index);
|
|
}
|
|
|
|
/**
|
|
* Updates the last assistant message.
|
|
* Subclasses can override this to customize tracking behavior.
|
|
*
|
|
* @param message - Assistant message to track
|
|
*/
|
|
protected updateLastAssistantMessage(message: CLIAssistantMessage): void {
|
|
this.lastAssistantMessage = message;
|
|
}
|
|
|
|
// ========== Shared Content Block Methods ==========
|
|
|
|
/**
|
|
* Appends text content to the current message.
|
|
* Uses template method pattern with hooks for stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param fragment - Text fragment to append
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected appendText(
|
|
state: MessageState,
|
|
fragment: string,
|
|
parentToolUseId: string | null,
|
|
): void {
|
|
if (fragment.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this.ensureBlockTypeConsistency(state, 'text', parentToolUseId);
|
|
this.ensureMessageStarted(state, parentToolUseId);
|
|
|
|
let current = state.blocks[state.blocks.length - 1] as
|
|
| TextBlock
|
|
| undefined;
|
|
const isNewBlock = !current || current.type !== 'text';
|
|
if (isNewBlock) {
|
|
current = { type: 'text', text: '' } satisfies TextBlock;
|
|
const index = state.blocks.length;
|
|
state.blocks.push(current);
|
|
this.openBlock(state, index, current);
|
|
this.onTextBlockCreated(state, index, current, parentToolUseId);
|
|
}
|
|
|
|
// current is guaranteed to be defined here (either existing or newly created)
|
|
current!.text += fragment;
|
|
const index = state.blocks.length - 1;
|
|
this.onTextAppended(state, index, fragment, parentToolUseId);
|
|
}
|
|
|
|
/**
|
|
* Appends thinking content to the current message.
|
|
* Uses template method pattern with hooks for stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param subject - Thinking subject
|
|
* @param description - Thinking description
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected appendThinking(
|
|
state: MessageState,
|
|
subject?: string,
|
|
description?: string,
|
|
parentToolUseId?: string | null,
|
|
): void {
|
|
const actualParentToolUseId = parentToolUseId ?? null;
|
|
|
|
// Build fragment without trimming to preserve whitespace in streaming content
|
|
// Only filter out null/undefined/empty values
|
|
const parts: string[] = [];
|
|
if (subject && subject.length > 0) {
|
|
parts.push(subject);
|
|
}
|
|
if (description && description.length > 0) {
|
|
parts.push(description);
|
|
}
|
|
|
|
const fragment = parts.join(': ');
|
|
if (!fragment) {
|
|
return;
|
|
}
|
|
|
|
this.ensureBlockTypeConsistency(state, 'thinking', actualParentToolUseId);
|
|
this.ensureMessageStarted(state, actualParentToolUseId);
|
|
|
|
let current = state.blocks[state.blocks.length - 1] as
|
|
| ThinkingBlock
|
|
| undefined;
|
|
const isNewBlock = !current || current.type !== 'thinking';
|
|
if (isNewBlock) {
|
|
current = {
|
|
type: 'thinking',
|
|
thinking: '',
|
|
signature: subject,
|
|
} satisfies ThinkingBlock;
|
|
const index = state.blocks.length;
|
|
state.blocks.push(current);
|
|
this.openBlock(state, index, current);
|
|
this.onThinkingBlockCreated(state, index, current, actualParentToolUseId);
|
|
}
|
|
|
|
// current is guaranteed to be defined here (either existing or newly created)
|
|
current!.thinking = `${current!.thinking ?? ''}${fragment}`;
|
|
const index = state.blocks.length - 1;
|
|
this.onThinkingAppended(state, index, fragment, actualParentToolUseId);
|
|
}
|
|
|
|
/**
|
|
* Appends a tool_use block to the current message.
|
|
* Uses template method pattern with hooks for stream events.
|
|
*
|
|
* @param state - Message state
|
|
* @param request - Tool call request info
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected appendToolUse(
|
|
state: MessageState,
|
|
request: ToolCallRequestInfo,
|
|
parentToolUseId: string | null,
|
|
): void {
|
|
this.ensureBlockTypeConsistency(state, 'tool_use', parentToolUseId);
|
|
this.ensureMessageStarted(state, parentToolUseId);
|
|
this.finalizePendingBlocks(state, parentToolUseId);
|
|
|
|
const index = state.blocks.length;
|
|
const block: ToolUseBlock = {
|
|
type: 'tool_use',
|
|
id: request.callId,
|
|
name: request.name,
|
|
input: request.args,
|
|
};
|
|
state.blocks.push(block);
|
|
this.openBlock(state, index, block);
|
|
|
|
// Emit tool_use block creation event (with empty input)
|
|
const startBlock: ToolUseBlock = {
|
|
type: 'tool_use',
|
|
id: request.callId,
|
|
name: request.name,
|
|
input: {},
|
|
};
|
|
this.onToolUseBlockCreated(state, index, startBlock, parentToolUseId);
|
|
this.onToolUseInputSet(state, index, request.args ?? {}, parentToolUseId);
|
|
|
|
this.onBlockClosed(state, index, parentToolUseId);
|
|
this.closeBlock(state, index);
|
|
}
|
|
|
|
/**
|
|
* Ensures that a message has been started.
|
|
* Calls hook method for subclasses to emit message_start events.
|
|
*
|
|
* @param state - Message state
|
|
* @param parentToolUseId - null for main agent, string for subagent
|
|
*/
|
|
protected ensureMessageStarted(
|
|
state: MessageState,
|
|
parentToolUseId: string | null,
|
|
): void {
|
|
if (state.messageStarted) {
|
|
return;
|
|
}
|
|
state.messageStarted = true;
|
|
this.onEnsureMessageStarted(state, parentToolUseId);
|
|
}
|
|
|
|
/**
|
|
* Creates and adds a tool_use block to the state.
|
|
* This is a shared helper method used by processSubagentToolCall implementations.
|
|
*
|
|
* @param state - Message state
|
|
* @param toolCall - Tool call information
|
|
* @param parentToolUseId - Parent tool use ID
|
|
* @returns The created block and its index
|
|
*/
|
|
protected createSubagentToolUseBlock(
|
|
state: MessageState,
|
|
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
|
_parentToolUseId: string,
|
|
): { block: ToolUseBlock; index: number } {
|
|
const index = state.blocks.length;
|
|
const block: ToolUseBlock = {
|
|
type: 'tool_use',
|
|
id: toolCall.callId,
|
|
name: toolCall.name,
|
|
input: toolCall.args || {},
|
|
};
|
|
state.blocks.push(block);
|
|
this.openBlock(state, index, block);
|
|
return { block, index };
|
|
}
|
|
|
|
/**
|
|
* Emits a user message.
|
|
* @param parts - Array of Part objects
|
|
* @param parentToolUseId - Optional parent tool use ID for subagent messages
|
|
*/
|
|
emitUserMessage(parts: Part[], parentToolUseId?: string | null): void {
|
|
const content = partsToContentBlock(parts);
|
|
const message: CLIUserMessage = {
|
|
type: 'user',
|
|
uuid: randomUUID(),
|
|
session_id: this.getSessionId(),
|
|
parent_tool_use_id: parentToolUseId ?? null,
|
|
message: {
|
|
role: 'user',
|
|
content,
|
|
},
|
|
};
|
|
this.emitMessageImpl(message);
|
|
}
|
|
|
|
/**
|
|
* Checks if responseParts contain any functionResponse with an error.
|
|
* This handles cancelled responses and other error cases where the error
|
|
* is embedded in responseParts rather than the top-level error field.
|
|
* @param responseParts - Array of Part objects
|
|
* @returns Error message if found, undefined otherwise
|
|
*/
|
|
private checkResponsePartsForError(
|
|
responseParts: Part[] | undefined,
|
|
): string | undefined {
|
|
// Use the shared helper function defined at file level
|
|
return checkResponsePartsForError(responseParts);
|
|
}
|
|
|
|
/**
|
|
* Emits a tool result message.
|
|
* Collects execution denied tool calls for inclusion in result messages.
|
|
* Handles both explicit errors (response.error) and errors embedded in
|
|
* responseParts (e.g., cancelled responses).
|
|
* @param request - Tool call request info
|
|
* @param response - Tool call response info
|
|
* @param parentToolUseId - Parent tool use ID (null for main agent)
|
|
*/
|
|
emitToolResult(
|
|
request: ToolCallRequestInfo,
|
|
response: ToolCallResponseInfo,
|
|
parentToolUseId: string | null = null,
|
|
): void {
|
|
// Check for errors in responseParts (e.g., cancelled responses)
|
|
const responsePartsError = this.checkResponsePartsForError(
|
|
response.responseParts,
|
|
);
|
|
|
|
// Determine if this is an error response
|
|
const hasError = Boolean(response.error) || Boolean(responsePartsError);
|
|
|
|
// Track permission denials (execution denied errors)
|
|
if (
|
|
response.error &&
|
|
response.errorType === ToolErrorType.EXECUTION_DENIED
|
|
) {
|
|
const denial: CLIPermissionDenial = {
|
|
tool_name: request.name,
|
|
tool_use_id: request.callId,
|
|
tool_input: request.args,
|
|
};
|
|
this.permissionDenials.push(denial);
|
|
}
|
|
|
|
const block: ToolResultBlock = {
|
|
type: 'tool_result',
|
|
tool_use_id: request.callId,
|
|
is_error: hasError,
|
|
};
|
|
const content = toolResultContent(response);
|
|
let projectedContent: string | undefined;
|
|
if (content !== undefined) {
|
|
projectedContent = projectHeadlessToolResultContent(content);
|
|
block.content = projectedContent;
|
|
}
|
|
|
|
const message: CLIUserMessage = {
|
|
type: 'user',
|
|
uuid: randomUUID(),
|
|
session_id: this.getSessionId(),
|
|
parent_tool_use_id: parentToolUseId,
|
|
message: {
|
|
role: 'user',
|
|
content: [block],
|
|
},
|
|
};
|
|
if (content !== undefined && projectedContent !== undefined) {
|
|
observeHeadlessToolResultProjection(
|
|
message,
|
|
content,
|
|
projectedContent,
|
|
request.callId,
|
|
response.boundaryArtifact ??
|
|
toolResultBoundaryArtifact(
|
|
response.persistedOutputFiles,
|
|
response.artifacts,
|
|
),
|
|
);
|
|
}
|
|
this.emitMessageImpl(message);
|
|
}
|
|
|
|
/**
|
|
* Emits a system message.
|
|
* @param subtype - System message subtype
|
|
* @param data - Optional data payload
|
|
*/
|
|
emitSystemMessage(subtype: string, data?: unknown): void {
|
|
const systemMessage = {
|
|
type: 'system',
|
|
subtype,
|
|
uuid: randomUUID(),
|
|
session_id: this.getSessionId(),
|
|
parent_tool_use_id: null,
|
|
data,
|
|
} as const;
|
|
this.emitMessageImpl(systemMessage);
|
|
}
|
|
|
|
/**
|
|
* Emits a `can_use_tool` permission control_request so an external consumer
|
|
* can approve or deny the tool call. Pairs with {@link emitControlResponse}.
|
|
*/
|
|
emitPermissionRequest(
|
|
requestId: string,
|
|
toolName: string,
|
|
toolUseId: string,
|
|
input: unknown,
|
|
blockedPath: string | null = null,
|
|
permissionSuggestions: PermissionSuggestion[] | null = null,
|
|
): void {
|
|
const message: ControlMessage = {
|
|
type: 'control_request',
|
|
request_id: requestId,
|
|
request: {
|
|
subtype: 'can_use_tool',
|
|
tool_name: toolName,
|
|
tool_use_id: toolUseId,
|
|
input,
|
|
permission_suggestions: permissionSuggestions,
|
|
blocked_path: blockedPath,
|
|
},
|
|
};
|
|
this.emitControlMessageImpl(message);
|
|
}
|
|
|
|
/**
|
|
* Emits a control_response carrying a permission approval result.
|
|
* Used both to mirror TUI-native resolutions back to external consumers
|
|
* and to acknowledge externally-supplied confirmation_responses.
|
|
*/
|
|
emitControlResponse(requestId: string, allowed: boolean): void {
|
|
const message: ControlMessage = {
|
|
type: 'control_response',
|
|
response: {
|
|
subtype: 'success',
|
|
request_id: requestId,
|
|
response: { allowed },
|
|
},
|
|
};
|
|
this.emitControlMessageImpl(message);
|
|
}
|
|
|
|
/**
|
|
* Emits a control_response with subtype `error`. Used to reject an
|
|
* external confirmation_response that cannot be honored (unknown
|
|
* request_id, tool call already resolved, etc.) so the consumer can
|
|
* surface or retry, instead of waiting forever for an implicit ack.
|
|
*/
|
|
emitControlError(requestId: string, errorMessage: string): void {
|
|
const message: ControlMessage = {
|
|
type: 'control_response',
|
|
response: {
|
|
subtype: 'error',
|
|
request_id: requestId,
|
|
error: errorMessage,
|
|
},
|
|
};
|
|
this.emitControlMessageImpl(message);
|
|
}
|
|
|
|
/**
|
|
* Emits a tool progress stream event.
|
|
* Default implementation is a no-op. StreamJsonOutputAdapter overrides this
|
|
* to emit stream events when includePartialMessages is enabled.
|
|
*
|
|
* @param _request - Tool call request info
|
|
* @param _progress - Structured MCP progress data or shell liveness heartbeat
|
|
*/
|
|
emitToolProgress(
|
|
_request: ToolCallRequestInfo,
|
|
_progress: McpToolProgressData | ShellProgressData,
|
|
): void {
|
|
// No-op in base class. Only StreamJsonOutputAdapter emits tool progress
|
|
// as stream events when includePartialMessages is enabled.
|
|
}
|
|
|
|
/**
|
|
* Builds a result message from options.
|
|
* Helper method used by both emitResult implementations.
|
|
* Includes permission denials collected from execution denied tool calls.
|
|
* @param options - Result options
|
|
* @param lastAssistantMessage - Last assistant message for text extraction
|
|
* @returns CLIResultMessage
|
|
*/
|
|
protected buildResultMessage(
|
|
options: ResultOptions,
|
|
lastAssistantMessage: CLIAssistantMessage | null,
|
|
): CLIResultMessage {
|
|
const usage = options.usage ?? createExtendedUsage();
|
|
const resultText =
|
|
options.summary ??
|
|
(lastAssistantMessage
|
|
? extractTextFromBlocks(lastAssistantMessage.message.content)
|
|
: '');
|
|
|
|
const baseUuid = randomUUID();
|
|
const baseSessionId = this.getSessionId();
|
|
|
|
if (options.isError) {
|
|
const errorMessage = options.errorMessage ?? 'Unknown error';
|
|
return {
|
|
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,
|
|
usage,
|
|
permission_denials: [...this.permissionDenials],
|
|
error: { message: errorMessage },
|
|
};
|
|
} else {
|
|
// Track presence by property existence — `runNonInteractive` may
|
|
// legitimately pass `structuredResult: undefined` (e.g. the model
|
|
// called structured_output with no args under an empty schema).
|
|
// A `!== undefined` sentinel would silently fall back to the
|
|
// free-text `resultText` path and drop the `structured_result`
|
|
// field, breaking the structured-output contract.
|
|
//
|
|
// Normalize an `undefined` submission to `null` so both
|
|
// `JSON.stringify` (for `result`) and the top-level
|
|
// `structured_result` field render as a JSON-safe `null` instead
|
|
// of being silently omitted.
|
|
const hasStructured = 'structuredResult' in options;
|
|
const normalizedStructured = hasStructured
|
|
? (options.structuredResult ?? null)
|
|
: undefined;
|
|
const finalResult = hasStructured
|
|
? JSON.stringify(normalizedStructured)
|
|
: resultText;
|
|
const success: CLIResultMessageSuccess & {
|
|
stats?: SessionMetrics;
|
|
structured_result?: unknown;
|
|
} = {
|
|
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: finalResult,
|
|
usage,
|
|
permission_denials: [...this.permissionDenials],
|
|
};
|
|
|
|
if (options.stats) {
|
|
success.stats = options.stats;
|
|
}
|
|
if (hasStructured) {
|
|
success.structured_result = normalizedStructured;
|
|
}
|
|
|
|
return success;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds a subagent error result message.
|
|
* Helper method used by both emitSubagentErrorResult implementations.
|
|
* Note: Subagent permission denials are not included here as they are tracked
|
|
* separately and would be included in the main agent's result message.
|
|
* @param errorMessage - Error message
|
|
* @param numTurns - Number of turns
|
|
* @returns CLIResultMessageError
|
|
*/
|
|
protected buildSubagentErrorResult(
|
|
errorMessage: string,
|
|
numTurns: number,
|
|
): CLIResultMessageError {
|
|
const usage: ExtendedUsage = {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
};
|
|
|
|
return {
|
|
type: 'result',
|
|
subtype: 'error_during_execution',
|
|
uuid: randomUUID(),
|
|
session_id: this.getSessionId(),
|
|
is_error: true,
|
|
duration_ms: 0,
|
|
duration_api_ms: 0,
|
|
num_turns: numTurns,
|
|
usage,
|
|
permission_denials: [],
|
|
error: { message: errorMessage },
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts Part array to ContentBlock array.
|
|
* Handles various Part types including text, functionResponse, and other types.
|
|
* For functionResponse parts, extracts the output content.
|
|
* For other non-text parts, converts them to text representation.
|
|
*
|
|
* @param parts - Array of Part objects
|
|
* @returns Array of ContentBlock objects (primarily TextBlock)
|
|
*/
|
|
export function partsToContentBlock(parts: Part[]): ContentBlock[] {
|
|
const blocks: ContentBlock[] = [];
|
|
let currentTextBlock: TextBlock | null = null;
|
|
|
|
for (const part of parts) {
|
|
let textContent: string | null = null;
|
|
|
|
// Handle text parts
|
|
if ('text' in part && typeof part.text === 'string') {
|
|
textContent = part.text;
|
|
}
|
|
// Handle functionResponse parts - extract output content
|
|
else if ('functionResponse' in part && part.functionResponse) {
|
|
const output =
|
|
part.functionResponse.response?.['output'] ??
|
|
part.functionResponse.response?.['content'] ??
|
|
'';
|
|
textContent =
|
|
typeof output === 'string' ? output : JSON.stringify(output);
|
|
}
|
|
// Handle other part types - convert to JSON string
|
|
else {
|
|
textContent = JSON.stringify(part);
|
|
}
|
|
|
|
// If we have text content, add it to the current text block or create a new one
|
|
if (textContent !== null && textContent.length > 0) {
|
|
if (currentTextBlock === null) {
|
|
currentTextBlock = {
|
|
type: 'text',
|
|
text: textContent,
|
|
};
|
|
blocks.push(currentTextBlock);
|
|
} else {
|
|
// Append to existing text block
|
|
currentTextBlock.text += textContent;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return blocks array, or empty array if no content
|
|
return blocks;
|
|
}
|
|
|
|
/**
|
|
* Converts Part array to string representation.
|
|
* This is a legacy function kept for backward compatibility.
|
|
* For new code, prefer using partsToContentBlock.
|
|
*
|
|
* @param parts - Array of Part objects
|
|
* @returns String representation
|
|
*/
|
|
export 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('');
|
|
}
|
|
|
|
/**
|
|
* Checks if responseParts contain any functionResponse with an error.
|
|
* Helper function for extracting error messages from responseParts.
|
|
* @param responseParts - Array of Part objects
|
|
* @returns Error message if found, undefined otherwise
|
|
*/
|
|
function checkResponsePartsForError(
|
|
responseParts: Part[] | undefined,
|
|
): string | undefined {
|
|
if (!responseParts || responseParts.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
for (const part of responseParts) {
|
|
if (
|
|
'functionResponse' in part &&
|
|
part.functionResponse?.response &&
|
|
typeof part.functionResponse.response === 'object' &&
|
|
'error' in part.functionResponse.response &&
|
|
part.functionResponse.response['error']
|
|
) {
|
|
const error = part.functionResponse.response['error'];
|
|
return typeof error === 'string' ? error : String(error);
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Extracts content from tool response.
|
|
* Uses functionResponsePartsToString to properly handle functionResponse parts,
|
|
* which correctly extracts output content from functionResponse objects rather
|
|
* than simply concatenating text or JSON.stringify.
|
|
* Also handles errors embedded in responseParts (e.g., cancelled responses).
|
|
*
|
|
* @param response - Tool call response
|
|
* @returns String content or undefined
|
|
*/
|
|
function mcpAppFallbackText(display: unknown): string | undefined {
|
|
if (
|
|
!display ||
|
|
typeof display !== 'object' ||
|
|
!('type' in display) ||
|
|
display.type !== 'mcp_app' ||
|
|
!('fallbackText' in display) ||
|
|
typeof display.fallbackText !== 'string'
|
|
) {
|
|
return undefined;
|
|
}
|
|
const text = display.fallbackText.trim();
|
|
return text.length > 0 ? display.fallbackText : undefined;
|
|
}
|
|
|
|
export function toolResultContent(
|
|
response: ToolCallResponseInfo,
|
|
): string | undefined {
|
|
if (response.visionBridgeNotice) {
|
|
const content = toolResultContent({
|
|
...response,
|
|
visionBridgeNotice: undefined,
|
|
});
|
|
return content
|
|
? `${response.visionBridgeNotice}\n${content}`
|
|
: response.visionBridgeNotice;
|
|
}
|
|
if (isVisionBridgeNoticeDisplay(response.resultDisplay)) {
|
|
const notice = formatVisionBridgeNoticeDisplay(response.resultDisplay);
|
|
if (response.error) {
|
|
return `${notice}\n${response.error.message}`;
|
|
}
|
|
const responsePartsError = checkResponsePartsForError(
|
|
response.responseParts,
|
|
);
|
|
if (responsePartsError) {
|
|
return `${notice}\n${responsePartsError}`;
|
|
}
|
|
if (response.responseParts && response.responseParts.length > 0) {
|
|
return `${notice}\n${functionResponsePartsToString(response.responseParts)}`;
|
|
}
|
|
return notice;
|
|
}
|
|
// Prefer model-facing detail over the short operational error summary.
|
|
const responsePartsError = checkResponsePartsForError(response.responseParts);
|
|
if (responsePartsError) {
|
|
return responsePartsError;
|
|
}
|
|
if (response.error) {
|
|
return response.error.message;
|
|
}
|
|
const mcpAppFallback = mcpAppFallbackText(response.resultDisplay);
|
|
if (mcpAppFallback) {
|
|
return mcpAppFallback;
|
|
}
|
|
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);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Extracts text from content blocks.
|
|
*
|
|
* @param blocks - Array of content blocks
|
|
* @returns Extracted text
|
|
*/
|
|
export function extractTextFromBlocks(blocks: ContentBlock[]): string {
|
|
return blocks
|
|
.filter((block) => block.type === 'text')
|
|
.map((block) => (block.type === 'text' ? block.text : ''))
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* Creates an extended usage object with default values.
|
|
*
|
|
* @returns ExtendedUsage object
|
|
*/
|
|
export function createExtendedUsage(): ExtendedUsage {
|
|
return {
|
|
input_tokens: 0,
|
|
output_tokens: 0,
|
|
};
|
|
}
|