diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 43b00c33..a10304d1 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -392,6 +392,49 @@ describe('parseArguments', () => { mockConsoleError.mockRestore(); }); + it('should throw an error when include-partial-messages is used without stream-json output', async () => { + process.argv = ['node', 'script.js', '--include-partial-messages']; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + '--include-partial-messages requires --output-format stream-json', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + + it('should parse stream-json formats and include-partial-messages flag', async () => { + process.argv = [ + 'node', + 'script.js', + '--output-format', + 'stream-json', + '--input-format', + 'stream-json', + '--include-partial-messages', + ]; + + const argv = await parseArguments({} as Settings); + + expect(argv.outputFormat).toBe('stream-json'); + expect(argv.inputFormat).toBe('stream-json'); + expect(argv.includePartialMessages).toBe(true); + }); + it('should allow --approval-mode without --yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit']; const argv = await parseArguments({} as Settings); @@ -473,6 +516,34 @@ describe('loadCliConfig', () => { vi.restoreAllMocks(); }); + it('should propagate stream-json formats to config', async () => { + process.argv = [ + 'node', + 'script.js', + '--output-format', + 'stream-json', + '--input-format', + 'stream-json', + '--include-partial-messages', + ]; + const argv = await parseArguments({} as Settings); + const settings: Settings = {}; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + + expect(config.getOutputFormat()).toBe('stream-json'); + expect(config.getInputFormat()).toBe('stream-json'); + expect(config.getIncludePartialMessages()).toBe(true); + }); + it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => { process.argv = ['node', 'script.js', '--show-memory-usage']; const argv = await parseArguments({} as Settings); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f7752df6..6c24b241 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -7,7 +7,6 @@ import type { FileFilteringOptions, MCPServerConfig, - OutputFormat, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { @@ -24,6 +23,7 @@ import { WriteFileTool, resolveTelemetrySettings, FatalConfigError, + OutputFormat, } from '@qwen-code/qwen-code-core'; import type { Settings } from './settings.js'; import yargs, { type Argv } from 'yargs'; @@ -119,7 +119,24 @@ export interface CliArgs { screenReader: boolean | undefined; vlmSwitchMode: string | undefined; useSmartEdit: boolean | undefined; + inputFormat?: string | undefined; outputFormat: string | undefined; + includePartialMessages?: boolean; +} + +function normalizeOutputFormat( + format: string | OutputFormat | undefined, +): OutputFormat | 'stream-json' | undefined { + if (!format) { + return undefined; + } + if (format === 'stream-json') { + return 'stream-json'; + } + if (format === 'json' || format === OutputFormat.JSON) { + return OutputFormat.JSON; + } + return OutputFormat.TEXT; } export async function parseArguments(settings: Settings): Promise { @@ -337,11 +354,23 @@ export async function parseArguments(settings: Settings): Promise { 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', default: process.env['VLM_SWITCH_MODE'], }) + .option('input-format', { + type: 'string', + choices: ['text', 'stream-json'], + description: 'The format consumed from standard input.', + default: 'text', + }) .option('output-format', { alias: 'o', type: 'string', description: 'The format of the CLI output.', - choices: ['text', 'json'], + choices: ['text', 'json', 'stream-json'], + }) + .option('include-partial-messages', { + type: 'boolean', + description: + 'Include partial assistant messages when using stream-json output.', + default: false, }) .deprecateOption( 'show-memory-usage', @@ -386,6 +415,12 @@ export async function parseArguments(settings: Settings): Promise { if (argv['yolo'] && argv['approvalMode']) { return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.'; } + if ( + argv['includePartialMessages'] && + argv['outputFormat'] !== 'stream-json' + ) { + return '--include-partial-messages requires --output-format stream-json'; + } return true; }), ) @@ -566,6 +601,21 @@ export async function loadCliConfig( let mcpServers = mergeMcpServers(settings, activeExtensions); const question = argv.promptInteractive || argv.prompt || ''; + const inputFormat = + (argv.inputFormat as 'text' | 'stream-json' | undefined) ?? 'text'; + const argvOutputFormat = normalizeOutputFormat( + argv.outputFormat as string | OutputFormat | undefined, + ); + const settingsOutputFormat = normalizeOutputFormat(settings.output?.format); + const outputFormat = + argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT; + const outputSettingsFormat: OutputFormat = + outputFormat === 'stream-json' + ? settingsOutputFormat && settingsOutputFormat !== 'stream-json' + ? settingsOutputFormat + : OutputFormat.TEXT + : (outputFormat as OutputFormat); + const includePartialMessages = Boolean(argv.includePartialMessages); // Determine approval mode with backward compatibility let approvalMode: ApprovalMode; @@ -610,8 +660,10 @@ export async function loadCliConfig( // Interactive mode: explicit -i flag or (TTY + no args + no -p flag) const hasQuery = !!argv.query; const interactive = - !!argv.promptInteractive || - (process.stdin.isTTY && !hasQuery && !argv.prompt); + inputFormat === 'stream-json' + ? false + : !!argv.promptInteractive || + (process.stdin.isTTY && !hasQuery && !argv.prompt); // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; if (!interactive && !argv.experimentalAcp) { @@ -733,6 +785,9 @@ export async function loadCliConfig( blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], authType: settings.security?.auth?.selectedType, + inputFormat, + outputFormat, + includePartialMessages, generationConfig: { ...(settings.model?.generationConfig || {}), model: resolvedModel, @@ -772,7 +827,7 @@ export async function loadCliConfig( eventEmitter: appEvents, useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, output: { - format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, + format: outputSettingsFormat, }, }); } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 231c34a7..12b22de2 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -333,7 +333,9 @@ describe('gemini.tsx main function kitty protocol', () => { screenReader: undefined, vlmSwitchMode: undefined, useSmartEdit: undefined, + inputFormat: undefined, outputFormat: undefined, + includePartialMessages: undefined, }); await main(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 44878bad..b648670b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -214,6 +214,9 @@ export interface ConfigParameters { sandbox?: SandboxConfig; targetDir: string; debugMode: boolean; + inputFormat?: 'text' | 'stream-json'; + outputFormat?: OutputFormat | 'text' | 'json' | 'stream-json'; + includePartialMessages?: boolean; question?: string; fullContext?: boolean; coreTools?: string[]; @@ -282,6 +285,25 @@ export interface ConfigParameters { output?: OutputSettings; } +function normalizeConfigOutputFormat( + format: OutputFormat | 'text' | 'json' | 'stream-json' | undefined, +): OutputFormat | 'stream-json' | undefined { + if (!format) { + return undefined; + } + switch (format) { + case 'stream-json': + return 'stream-json'; + case 'json': + case OutputFormat.JSON: + return OutputFormat.JSON; + case 'text': + case OutputFormat.TEXT: + default: + return OutputFormat.TEXT; + } +} + export class Config { private toolRegistry!: ToolRegistry; private promptRegistry!: PromptRegistry; @@ -296,6 +318,9 @@ export class Config { private readonly targetDir: string; private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; + private readonly inputFormat: 'text' | 'stream-json'; + private readonly outputFormat: OutputFormat | 'stream-json'; + private readonly includePartialMessages: boolean; private readonly question: string | undefined; private readonly fullContext: boolean; private readonly coreTools: string[] | undefined; @@ -370,7 +395,6 @@ export class Config { private readonly enableToolOutputTruncation: boolean; private readonly eventEmitter?: EventEmitter; private readonly useSmartEdit: boolean; - private readonly outputSettings: OutputSettings; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -383,6 +407,12 @@ export class Config { params.includeDirectories ?? [], ); this.debugMode = params.debugMode; + this.inputFormat = params.inputFormat ?? 'text'; + const normalizedOutputFormat = normalizeConfigOutputFormat( + params.outputFormat ?? params.output?.format, + ); + this.outputFormat = normalizedOutputFormat ?? OutputFormat.TEXT; + this.includePartialMessages = params.includePartialMessages ?? false; this.question = params.question; this.fullContext = params.fullContext ?? false; this.coreTools = params.coreTools; @@ -480,10 +510,6 @@ export class Config { this.vlmSwitchMode = params.vlmSwitchMode; this.fileExclusions = new FileExclusions(this); this.eventEmitter = params.eventEmitter; - this.outputSettings = { - format: params.output?.format ?? OutputFormat.TEXT, - }; - if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); } @@ -768,6 +794,14 @@ export class Config { return this.showMemoryUsage; } + getInputFormat(): 'text' | 'stream-json' { + return this.inputFormat; + } + + getIncludePartialMessages(): boolean { + return this.includePartialMessages; + } + getAccessibility(): AccessibilitySettings { return this.accessibility; } @@ -1048,10 +1082,8 @@ export class Config { return this.useSmartEdit; } - getOutputFormat(): OutputFormat { - return this.outputSettings?.format - ? this.outputSettings.format - : OutputFormat.TEXT; + getOutputFormat(): OutputFormat | 'stream-json' { + return this.outputFormat; } async getGitService(): Promise { diff --git a/packages/core/src/core/nonInteractiveToolExecutor.ts b/packages/core/src/core/nonInteractiveToolExecutor.ts index 67407230..3575af96 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.ts @@ -9,7 +9,18 @@ import type { ToolCallResponseInfo, Config, } from '../index.js'; -import { CoreToolScheduler } from './coreToolScheduler.js'; +import { + CoreToolScheduler, + type AllToolCallsCompleteHandler, + type OutputUpdateHandler, + type ToolCallsUpdateHandler, +} from './coreToolScheduler.js'; + +export interface ExecuteToolCallOptions { + outputUpdateHandler?: OutputUpdateHandler; + onAllToolCallsComplete?: AllToolCallsCompleteHandler; + onToolCallsUpdate?: ToolCallsUpdateHandler; +} /** * Executes a single tool call non-interactively by leveraging the CoreToolScheduler. @@ -18,15 +29,21 @@ export async function executeToolCall( config: Config, toolCallRequest: ToolCallRequestInfo, abortSignal: AbortSignal, + options: ExecuteToolCallOptions = {}, ): Promise { return new Promise((resolve, reject) => { new CoreToolScheduler({ config, - getPreferredEditor: () => undefined, - onEditorClose: () => {}, + outputUpdateHandler: options.outputUpdateHandler, onAllToolCallsComplete: async (completedToolCalls) => { + if (options.onAllToolCallsComplete) { + await options.onAllToolCallsComplete(completedToolCalls); + } resolve(completedToolCalls[0].response); }, + onToolCallsUpdate: options.onToolCallsUpdate, + getPreferredEditor: () => undefined, + onEditorClose: () => {}, }) .schedule(toolCallRequest, abortSignal) .catch(reject); diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 1ba29116..0662058e 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -46,7 +46,7 @@ export class StartSessionEvent implements BaseTelemetryEvent { mcp_servers_count: number; mcp_tools_count?: number; mcp_tools?: string; - output_format: OutputFormat; + output_format: OutputFormat | 'stream-json'; constructor(config: Config, toolRegistry?: ToolRegistry) { const generatorConfig = config.getContentGeneratorConfig();