openspec/lightweight-tasks/task1-2-2.md

feat: add support for stream-json format and includePartialMessages flag in CLI arguments
This commit is contained in:
x22x22
2025-10-30 02:23:22 +08:00
parent 1ea157428c
commit eb1247d31e
6 changed files with 195 additions and 18 deletions

View File

@@ -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);

View File

@@ -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<CliArgs> {
@@ -337,11 +354,23 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.',
default: 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<CliArgs> {
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,7 +660,9 @@ export async function loadCliConfig(
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
const hasQuery = !!argv.query;
const interactive =
!!argv.promptInteractive ||
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[] = [];
@@ -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,
},
});
}

View File

@@ -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();

View File

@@ -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<GitService> {

View File

@@ -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<ToolCallResponseInfo> {
return new Promise<ToolCallResponseInfo>((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);

View File

@@ -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();