mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Headless enhancement: add stream-json as input-format/output-format to support programmatically use (#926)
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, type MockInstance } from 'vitest';
|
||||
import { vi, type Mock, type MockInstance } from 'vitest';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
@@ -83,6 +83,7 @@ describe('errors', () => {
|
||||
mockConfig = {
|
||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
||||
getDebugMode: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
@@ -254,105 +255,81 @@ describe('errors', () => {
|
||||
const toolName = 'test-tool';
|
||||
const toolError = new Error('Tool failed');
|
||||
|
||||
describe('in text mode', () => {
|
||||
describe('when debug mode is enabled', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should log error message to stderr', () => {
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
describe('in text mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use resultDisplay when provided', () => {
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
'CUSTOM_ERROR',
|
||||
'Custom display message',
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Custom display message',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in JSON mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.JSON);
|
||||
});
|
||||
|
||||
it('should format error as JSON and exit with default code', () => {
|
||||
expect(() => {
|
||||
it('should log error message to stderr and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
}).toThrow('process.exit called with code: 54');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalToolExecutionError',
|
||||
message: 'Error executing tool test-tool: Tool failed',
|
||||
code: 54,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use resultDisplay when provided and not exit', () => {
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
mockConfig,
|
||||
'CUSTOM_ERROR',
|
||||
'Custom display message',
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Custom display message',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom error code', () => {
|
||||
expect(() => {
|
||||
describe('in JSON mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.JSON);
|
||||
});
|
||||
|
||||
it('should log error message to stderr and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
|
||||
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log error with custom error code and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR');
|
||||
}).toThrow('process.exit called with code: 54');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalToolExecutionError',
|
||||
message: 'Error executing tool test-tool: Tool failed',
|
||||
code: 'CUSTOM_TOOL_ERROR',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use numeric error code and exit with that code', () => {
|
||||
expect(() => {
|
||||
it('should log error with numeric error code and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig, 500);
|
||||
}).toThrow('process.exit called with code: 500');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalToolExecutionError',
|
||||
message: 'Error executing tool test-tool: Tool failed',
|
||||
code: 500,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer resultDisplay over error message', () => {
|
||||
expect(() => {
|
||||
it('should prefer resultDisplay over error message and not exit', () => {
|
||||
handleToolError(
|
||||
toolName,
|
||||
toolError,
|
||||
@@ -360,21 +337,99 @@ describe('errors', () => {
|
||||
'DISPLAY_ERROR',
|
||||
'Display message',
|
||||
);
|
||||
}).toThrow('process.exit called with code: 54');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
error: {
|
||||
type: 'FatalToolExecutionError',
|
||||
message: 'Error executing tool test-tool: Display message',
|
||||
code: 'DISPLAY_ERROR',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Display message',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in STREAM_JSON mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||
});
|
||||
|
||||
it('should log error message to stderr and not exit', () => {
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
|
||||
// Should not exit in STREAM_JSON mode (just log to stderr when debug mode is on)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool test-tool: Tool failed',
|
||||
);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when debug mode is disabled', () => {
|
||||
beforeEach(() => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should not log and not exit in text mode', () => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log and not exit in JSON mode', () => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log and not exit in STREAM_JSON mode', () => {
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('process exit behavior', () => {
|
||||
beforeEach(() => {
|
||||
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should never exit regardless of output format', () => {
|
||||
// Test in TEXT mode
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.TEXT);
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Test in JSON mode
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.JSON);
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Test in STREAM_JSON mode
|
||||
(
|
||||
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue(OutputFormat.STREAM_JSON);
|
||||
handleToolError(toolName, toolError, mockConfig);
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
JsonFormatter,
|
||||
parseAndFormatApiError,
|
||||
FatalTurnLimitedError,
|
||||
FatalToolExecutionError,
|
||||
FatalCancellationError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
@@ -88,32 +87,29 @@ export function handleError(
|
||||
|
||||
/**
|
||||
* Handles tool execution errors specifically.
|
||||
* In JSON mode, outputs formatted JSON error and exits.
|
||||
* In JSON/STREAM_JSON mode, outputs error message to stderr only and does not exit.
|
||||
* The error will be properly formatted in the tool_result block by the adapter,
|
||||
* allowing the session to continue so the LLM can decide what to do next.
|
||||
* In text mode, outputs error message to stderr only.
|
||||
*
|
||||
* @param toolName - Name of the tool that failed
|
||||
* @param toolError - The error that occurred during tool execution
|
||||
* @param config - Configuration object
|
||||
* @param errorCode - Optional error code
|
||||
* @param resultDisplay - Optional display message for the error
|
||||
*/
|
||||
export function handleToolError(
|
||||
toolName: string,
|
||||
toolError: Error,
|
||||
config: Config,
|
||||
errorCode?: string | number,
|
||||
_errorCode?: string | number,
|
||||
resultDisplay?: string,
|
||||
): void {
|
||||
const errorMessage = `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`;
|
||||
const toolExecutionError = new FatalToolExecutionError(errorMessage);
|
||||
|
||||
if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const formattedError = formatter.formatError(
|
||||
toolExecutionError,
|
||||
errorCode ?? toolExecutionError.exitCode,
|
||||
// Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
`Error executing tool ${toolName}: ${resultDisplay || toolError.message}`,
|
||||
);
|
||||
|
||||
console.error(formattedError);
|
||||
process.exit(
|
||||
typeof errorCode === 'number' ? errorCode : toolExecutionError.exitCode,
|
||||
);
|
||||
} else {
|
||||
console.error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1168
packages/cli/src/utils/nonInteractiveHelpers.test.ts
Normal file
1168
packages/cli/src/utils/nonInteractiveHelpers.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
624
packages/cli/src/utils/nonInteractiveHelpers.ts
Normal file
624
packages/cli/src/utils/nonInteractiveHelpers.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ToolResultDisplay,
|
||||
TaskResultDisplay,
|
||||
OutputUpdateHandler,
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
SessionMetrics,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
OutputFormat,
|
||||
ToolErrorType,
|
||||
getMCPServerStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type {
|
||||
CLIUserMessage,
|
||||
Usage,
|
||||
PermissionMode,
|
||||
CLISystemMessage,
|
||||
} from '../nonInteractive/types.js';
|
||||
import { CommandService } from '../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
|
||||
import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js';
|
||||
import { computeSessionStats } from '../ui/utils/computeStats.js';
|
||||
|
||||
/**
|
||||
* Normalizes various part list formats into a consistent Part[] array.
|
||||
*
|
||||
* @param parts - Input parts in various formats (string, Part, Part[], or null)
|
||||
* @returns Normalized array of Part objects
|
||||
*/
|
||||
export function normalizePartList(parts: PartListUnion | null): Part[] {
|
||||
if (!parts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof parts === 'string') {
|
||||
return [{ text: parts }];
|
||||
}
|
||||
|
||||
if (Array.isArray(parts)) {
|
||||
return parts.map((part) =>
|
||||
typeof part === 'string' ? { text: part } : (part as Part),
|
||||
);
|
||||
}
|
||||
|
||||
return [parts as Part];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts user message parts from a CLI protocol message.
|
||||
*
|
||||
* @param message - User message sourced from the CLI protocol layer
|
||||
* @returns Extracted parts or null if the message lacks textual content
|
||||
*/
|
||||
export function extractPartsFromUserMessage(
|
||||
message: CLIUserMessage | undefined,
|
||||
): PartListUnion | null {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = message.message?.content;
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const parts: Part[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object' || !('type' in block)) {
|
||||
continue;
|
||||
}
|
||||
if (block.type === 'text' && 'text' in block && block.text) {
|
||||
parts.push({ text: block.text });
|
||||
} else {
|
||||
parts.push({ text: JSON.stringify(block) });
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts usage metadata from the Gemini client's debug responses.
|
||||
*
|
||||
* @param geminiClient - The Gemini client instance
|
||||
* @returns Usage information or undefined if not available
|
||||
*/
|
||||
export function extractUsageFromGeminiClient(
|
||||
geminiClient: unknown,
|
||||
): Usage | undefined {
|
||||
if (
|
||||
!geminiClient ||
|
||||
typeof geminiClient !== 'object' ||
|
||||
typeof (geminiClient as { getChat?: unknown }).getChat !== 'function'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const chat = (geminiClient as { getChat: () => unknown }).getChat();
|
||||
if (
|
||||
!chat ||
|
||||
typeof chat !== 'object' ||
|
||||
typeof (chat as { getDebugResponses?: unknown }).getDebugResponses !==
|
||||
'function'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const responses = (
|
||||
chat as {
|
||||
getDebugResponses: () => Array<Record<string, unknown>>;
|
||||
}
|
||||
).getDebugResponses();
|
||||
for (let i = responses.length - 1; i >= 0; i--) {
|
||||
const metadata = responses[i]?.['usageMetadata'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (metadata) {
|
||||
const promptTokens = metadata['promptTokenCount'];
|
||||
const completionTokens = metadata['candidatesTokenCount'];
|
||||
const totalTokens = metadata['totalTokenCount'];
|
||||
const cachedTokens = metadata['cachedContentTokenCount'];
|
||||
|
||||
return {
|
||||
input_tokens: typeof promptTokens === 'number' ? promptTokens : 0,
|
||||
output_tokens:
|
||||
typeof completionTokens === 'number' ? completionTokens : 0,
|
||||
total_tokens:
|
||||
typeof totalTokens === 'number' ? totalTokens : undefined,
|
||||
cache_read_input_tokens:
|
||||
typeof cachedTokens === 'number' ? cachedTokens : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to extract usage metadata:', error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes Usage information from SessionMetrics using computeSessionStats.
|
||||
* Aggregates token usage across all models in the session.
|
||||
*
|
||||
* @param metrics - Session metrics from uiTelemetryService
|
||||
* @returns Usage object with token counts
|
||||
*/
|
||||
export function computeUsageFromMetrics(metrics: SessionMetrics): Usage {
|
||||
const stats = computeSessionStats(metrics);
|
||||
const { models } = metrics;
|
||||
|
||||
// Sum up output tokens (candidates) and total tokens across all models
|
||||
const totalOutputTokens = Object.values(models).reduce(
|
||||
(acc, model) => acc + model.tokens.candidates,
|
||||
0,
|
||||
);
|
||||
const totalTokens = Object.values(models).reduce(
|
||||
(acc, model) => acc + model.tokens.total,
|
||||
0,
|
||||
);
|
||||
|
||||
const usage: Usage = {
|
||||
input_tokens: stats.totalPromptTokens,
|
||||
output_tokens: totalOutputTokens,
|
||||
cache_read_input_tokens: stats.totalCachedTokens,
|
||||
};
|
||||
|
||||
// Only include total_tokens if it's greater than 0
|
||||
if (totalTokens > 0) {
|
||||
usage.total_tokens = totalTokens;
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load slash command names using CommandService
|
||||
*
|
||||
* @param config - Config instance
|
||||
* @returns Promise resolving to array of slash command names
|
||||
*/
|
||||
async function loadSlashCommandNames(config: Config): Promise<string[]> {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const service = await CommandService.create(
|
||||
[new BuiltinCommandLoader(config)],
|
||||
controller.signal,
|
||||
);
|
||||
const names = new Set<string>();
|
||||
const commands = service.getCommands();
|
||||
for (const command of commands) {
|
||||
names.add(command.name);
|
||||
}
|
||||
return Array.from(names).sort();
|
||||
} catch (error) {
|
||||
if (config.getDebugMode()) {
|
||||
console.error(
|
||||
'[buildSystemMessage] Failed to load slash commands:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system message for SDK
|
||||
*
|
||||
* Constructs a system initialization message including tools, MCP servers,
|
||||
* and model configuration. System messages are independent of the control
|
||||
* system and are sent before every turn regardless of whether control
|
||||
* system is available.
|
||||
*
|
||||
* Note: Control capabilities are NOT included in system messages. They
|
||||
* are only included in the initialize control response, which is handled
|
||||
* separately by SystemController.
|
||||
*
|
||||
* @param config - Config instance
|
||||
* @param sessionId - Session identifier
|
||||
* @param permissionMode - Current permission/approval mode
|
||||
* @returns Promise resolving to CLISystemMessage
|
||||
*/
|
||||
export async function buildSystemMessage(
|
||||
config: Config,
|
||||
sessionId: string,
|
||||
permissionMode: PermissionMode,
|
||||
): Promise<CLISystemMessage> {
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const tools = toolRegistry ? toolRegistry.getAllToolNames() : [];
|
||||
|
||||
const mcpServers = config.getMcpServers();
|
||||
const mcpServerList = mcpServers
|
||||
? Object.keys(mcpServers).map((name) => ({
|
||||
name,
|
||||
status: getMCPServerStatus(name),
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Load slash commands
|
||||
const slashCommands = await loadSlashCommandNames(config);
|
||||
|
||||
// Load subagent names from config
|
||||
let agentNames: string[] = [];
|
||||
try {
|
||||
const subagentManager = config.getSubagentManager();
|
||||
const subagents = await subagentManager.listSubagents();
|
||||
agentNames = subagents.map((subagent) => subagent.name);
|
||||
} catch (error) {
|
||||
if (config.getDebugMode()) {
|
||||
console.error('[buildSystemMessage] Failed to load subagents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const systemMessage: CLISystemMessage = {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: sessionId,
|
||||
session_id: sessionId,
|
||||
cwd: config.getTargetDir(),
|
||||
tools,
|
||||
mcp_servers: mcpServerList,
|
||||
model: config.getModel(),
|
||||
permissionMode,
|
||||
slash_commands: slashCommands,
|
||||
qwen_code_version: config.getCliVersion() || 'unknown',
|
||||
agents: agentNames,
|
||||
};
|
||||
|
||||
return systemMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an output update handler specifically for Task tool subagent execution.
|
||||
* This handler monitors TaskResultDisplay updates and converts them to protocol messages
|
||||
* using the unified adapter's subagent APIs. All emitted messages will have parent_tool_use_id set to
|
||||
* the task tool's callId.
|
||||
*
|
||||
* @param config - Config instance for getting output format
|
||||
* @param taskToolCallId - The task tool's callId to use as parent_tool_use_id for all subagent messages
|
||||
* @param adapter - The unified adapter instance (JsonOutputAdapter or StreamJsonOutputAdapter)
|
||||
* @returns An object containing the output update handler
|
||||
*/
|
||||
export function createTaskToolProgressHandler(
|
||||
config: Config,
|
||||
taskToolCallId: string,
|
||||
adapter: JsonOutputAdapterInterface | undefined,
|
||||
): {
|
||||
handler: OutputUpdateHandler;
|
||||
} {
|
||||
// Track previous TaskResultDisplay states per tool call to detect changes
|
||||
const previousTaskStates = new Map<string, TaskResultDisplay>();
|
||||
// Track which tool call IDs have already emitted tool_use to prevent duplicates
|
||||
const emittedToolUseIds = new Set<string>();
|
||||
// Track which tool call IDs have already emitted tool_result to prevent duplicates
|
||||
const emittedToolResultIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Builds a ToolCallRequestInfo object from a tool call.
|
||||
*
|
||||
* @param toolCall - The tool call information
|
||||
* @returns ToolCallRequestInfo object
|
||||
*/
|
||||
const buildRequest = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
): ToolCallRequestInfo => ({
|
||||
callId: toolCall.callId,
|
||||
name: toolCall.name,
|
||||
args: toolCall.args || {},
|
||||
isClientInitiated: true,
|
||||
prompt_id: '',
|
||||
response_id: undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds a ToolCallResponseInfo object from a tool call.
|
||||
*
|
||||
* @param toolCall - The tool call information
|
||||
* @returns ToolCallResponseInfo object
|
||||
*/
|
||||
const buildResponse = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
): ToolCallResponseInfo => ({
|
||||
callId: toolCall.callId,
|
||||
error:
|
||||
toolCall.status === 'failed'
|
||||
? new Error(toolCall.error || 'Tool execution failed')
|
||||
: undefined,
|
||||
errorType:
|
||||
toolCall.status === 'failed' ? ToolErrorType.EXECUTION_FAILED : undefined,
|
||||
resultDisplay: toolCall.resultDisplay,
|
||||
responseParts: toolCall.responseParts || [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if a tool call has result content that should be emitted.
|
||||
*
|
||||
* @param toolCall - The tool call information
|
||||
* @returns True if the tool call has result content to emit
|
||||
*/
|
||||
const hasResultContent = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
): boolean => {
|
||||
// Check resultDisplay string
|
||||
if (
|
||||
typeof toolCall.resultDisplay === 'string' &&
|
||||
toolCall.resultDisplay.trim().length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check responseParts - only check existence, don't parse for performance
|
||||
if (toolCall.responseParts && toolCall.responseParts.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Failed status should always emit result
|
||||
return toolCall.status === 'failed';
|
||||
};
|
||||
|
||||
/**
|
||||
* Emits tool_use for a tool call if it hasn't been emitted yet.
|
||||
*
|
||||
* @param toolCall - The tool call information
|
||||
* @param fallbackStatus - Optional fallback status if toolCall.status should be overridden
|
||||
*/
|
||||
const emitToolUseIfNeeded = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
fallbackStatus?: 'executing' | 'awaiting_approval',
|
||||
): void => {
|
||||
if (emittedToolUseIds.has(toolCall.callId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCallToEmit: NonNullable<TaskResultDisplay['toolCalls']>[number] =
|
||||
fallbackStatus
|
||||
? {
|
||||
...toolCall,
|
||||
status: fallbackStatus,
|
||||
}
|
||||
: toolCall;
|
||||
|
||||
if (
|
||||
toolCallToEmit.status === 'executing' ||
|
||||
toolCallToEmit.status === 'awaiting_approval'
|
||||
) {
|
||||
if (adapter?.processSubagentToolCall) {
|
||||
adapter.processSubagentToolCall(toolCallToEmit, taskToolCallId);
|
||||
emittedToolUseIds.add(toolCall.callId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Emits tool_result for a tool call if it hasn't been emitted yet and has content.
|
||||
*
|
||||
* @param toolCall - The tool call information
|
||||
*/
|
||||
const emitToolResultIfNeeded = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
): void => {
|
||||
if (emittedToolResultIds.has(toolCall.callId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasResultContent(toolCall)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as emitted even if we skip, to prevent duplicate emits
|
||||
emittedToolResultIds.add(toolCall.callId);
|
||||
|
||||
if (adapter) {
|
||||
const request = buildRequest(toolCall);
|
||||
const response = buildResponse(toolCall);
|
||||
// For subagent tool results, we need to pass parentToolUseId
|
||||
// The adapter implementations accept an optional parentToolUseId parameter
|
||||
if (
|
||||
'emitToolResult' in adapter &&
|
||||
typeof adapter.emitToolResult === 'function'
|
||||
) {
|
||||
adapter.emitToolResult(request, response, taskToolCallId);
|
||||
} else {
|
||||
adapter.emitToolResult(request, response);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes a tool call, ensuring tool_use and tool_result are emitted exactly once.
|
||||
*
|
||||
* @param toolCall - The tool call information
|
||||
* @param previousCall - The previous state of the tool call (if any)
|
||||
*/
|
||||
const processToolCall = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
previousCall?: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
): void => {
|
||||
const isCompleted =
|
||||
toolCall.status === 'success' || toolCall.status === 'failed';
|
||||
const isExecuting =
|
||||
toolCall.status === 'executing' ||
|
||||
toolCall.status === 'awaiting_approval';
|
||||
const wasExecuting =
|
||||
previousCall &&
|
||||
(previousCall.status === 'executing' ||
|
||||
previousCall.status === 'awaiting_approval');
|
||||
|
||||
// Emit tool_use if needed
|
||||
if (isExecuting) {
|
||||
// Normal case: tool call is executing or awaiting approval
|
||||
emitToolUseIfNeeded(toolCall);
|
||||
} else if (isCompleted && !emittedToolUseIds.has(toolCall.callId)) {
|
||||
// Edge case: tool call appeared with result already (shouldn't happen normally,
|
||||
// but handle it gracefully by emitting tool_use with 'executing' status first)
|
||||
emitToolUseIfNeeded(toolCall, 'executing');
|
||||
} else if (wasExecuting && isCompleted) {
|
||||
// Status changed from executing to completed - ensure tool_use was emitted
|
||||
emitToolUseIfNeeded(toolCall, 'executing');
|
||||
}
|
||||
|
||||
// Emit tool_result if tool call is completed
|
||||
if (isCompleted) {
|
||||
emitToolResultIfNeeded(toolCall);
|
||||
}
|
||||
};
|
||||
|
||||
const outputUpdateHandler = (
|
||||
callId: string,
|
||||
outputChunk: ToolResultDisplay,
|
||||
) => {
|
||||
// Only process TaskResultDisplay (Task tool updates)
|
||||
if (
|
||||
typeof outputChunk === 'object' &&
|
||||
outputChunk !== null &&
|
||||
'type' in outputChunk &&
|
||||
outputChunk.type === 'task_execution'
|
||||
) {
|
||||
const taskDisplay = outputChunk as TaskResultDisplay;
|
||||
const previous = previousTaskStates.get(callId);
|
||||
|
||||
// If no adapter, just track state (for non-JSON modes)
|
||||
if (!adapter) {
|
||||
previousTaskStates.set(callId, taskDisplay);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process if adapter supports subagent APIs
|
||||
if (
|
||||
!adapter.processSubagentToolCall ||
|
||||
!adapter.emitSubagentErrorResult
|
||||
) {
|
||||
previousTaskStates.set(callId, taskDisplay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskDisplay.toolCalls) {
|
||||
if (!previous || !previous.toolCalls) {
|
||||
// First time seeing tool calls - process all initial ones
|
||||
for (const toolCall of taskDisplay.toolCalls) {
|
||||
processToolCall(toolCall);
|
||||
}
|
||||
} else {
|
||||
// Compare with previous state to find new/changed tool calls
|
||||
for (const toolCall of taskDisplay.toolCalls) {
|
||||
const previousCall = previous.toolCalls.find(
|
||||
(tc) => tc.callId === toolCall.callId,
|
||||
);
|
||||
processToolCall(toolCall, previousCall);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle task-level errors (status: 'failed', 'cancelled')
|
||||
if (
|
||||
taskDisplay.status === 'failed' ||
|
||||
taskDisplay.status === 'cancelled'
|
||||
) {
|
||||
const previousStatus = previous?.status;
|
||||
// Only emit error result if status changed to failed/cancelled
|
||||
if (
|
||||
previousStatus !== 'failed' &&
|
||||
previousStatus !== 'cancelled' &&
|
||||
previousStatus !== undefined
|
||||
) {
|
||||
const errorMessage =
|
||||
taskDisplay.terminateReason ||
|
||||
(taskDisplay.status === 'cancelled'
|
||||
? 'Task was cancelled'
|
||||
: 'Task execution failed');
|
||||
// Use subagent adapter's emitSubagentErrorResult method
|
||||
adapter.emitSubagentErrorResult(errorMessage, 0, taskToolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle subagent initial message (prompt) in non-interactive mode with json/stream-json output
|
||||
// Emit when this is the first update (previous is undefined) and task starts
|
||||
if (
|
||||
!previous &&
|
||||
taskDisplay.taskPrompt &&
|
||||
!config.isInteractive() &&
|
||||
(config.getOutputFormat() === OutputFormat.JSON ||
|
||||
config.getOutputFormat() === OutputFormat.STREAM_JSON)
|
||||
) {
|
||||
// Emit the user message with the correct parent_tool_use_id
|
||||
adapter.emitUserMessage(
|
||||
[{ text: taskDisplay.taskPrompt }],
|
||||
taskToolCallId,
|
||||
);
|
||||
}
|
||||
|
||||
// Update previous state
|
||||
previousTaskStates.set(callId, taskDisplay);
|
||||
}
|
||||
};
|
||||
|
||||
// No longer need to attach adapter to handler - task.ts uses TaskResultDisplay.message instead
|
||||
|
||||
return {
|
||||
handler: outputUpdateHandler,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts function response parts to a string representation.
|
||||
* Handles functionResponse parts specially by extracting their output content.
|
||||
*
|
||||
* @param parts - Array of Part objects to convert
|
||||
* @returns String representation of the parts
|
||||
*/
|
||||
export function functionResponsePartsToString(parts: Part[]): string {
|
||||
return parts
|
||||
.map((part) => {
|
||||
if ('functionResponse' in part) {
|
||||
const content = part.functionResponse?.response?.['output'] ?? '';
|
||||
return content;
|
||||
}
|
||||
return JSON.stringify(part);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts content from a tool call response for inclusion in tool_result blocks.
|
||||
* Uses functionResponsePartsToString to properly handle functionResponse parts,
|
||||
* which correctly extracts output content from functionResponse objects rather
|
||||
* than simply concatenating text or JSON.stringify.
|
||||
*
|
||||
* @param response - Tool call response information
|
||||
* @returns String content for the tool_result block, or undefined if no content available
|
||||
*/
|
||||
export function toolResultContent(
|
||||
response: ToolCallResponseInfo,
|
||||
): string | undefined {
|
||||
if (
|
||||
typeof response.resultDisplay === 'string' &&
|
||||
response.resultDisplay.trim().length > 0
|
||||
) {
|
||||
return response.resultDisplay;
|
||||
}
|
||||
if (response.responseParts && response.responseParts.length > 0) {
|
||||
// Always use functionResponsePartsToString to properly handle
|
||||
// functionResponse parts that contain output content
|
||||
return functionResponsePartsToString(response.responseParts);
|
||||
}
|
||||
if (response.error) {
|
||||
return response.error.message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user