Headless enhancement: add stream-json as input-format/output-format to support programmatically use (#926)

This commit is contained in:
Kdump
2025-11-21 09:26:05 +08:00
committed by GitHub
parent 442a9aed58
commit 9e5387f159
50 changed files with 14559 additions and 534 deletions

View File

@@ -8,9 +8,16 @@
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"qwen": "dist/index.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "node ../../scripts/build_package.js",
"start": "node dist/index.js",

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,8 @@ import {
WriteFileTool,
resolveTelemetrySettings,
FatalConfigError,
InputFormat,
OutputFormat,
} from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
import yargs, { type Argv } from 'yargs';
@@ -124,7 +125,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 | undefined {
if (!format) {
return undefined;
}
if (format === OutputFormat.STREAM_JSON) {
return OutputFormat.STREAM_JSON;
}
if (format === 'json' || format === OutputFormat.JSON) {
return OutputFormat.JSON;
}
return OutputFormat.TEXT;
}
export async function parseArguments(settings: Settings): Promise<CliArgs> {
@@ -359,11 +377,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',
@@ -408,6 +438,18 @@ 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'] !== OutputFormat.STREAM_JSON
) {
return '--include-partial-messages requires --output-format stream-json';
}
if (
argv['inputFormat'] === 'stream-json' &&
argv['outputFormat'] !== OutputFormat.STREAM_JSON
) {
return '--input-format stream-json requires --output-format stream-json';
}
return true;
}),
)
@@ -588,6 +630,22 @@ export async function loadCliConfig(
let mcpServers = mergeMcpServers(settings, activeExtensions);
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.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 === OutputFormat.STREAM_JSON
? settingsOutputFormat &&
settingsOutputFormat !== OutputFormat.STREAM_JSON
? settingsOutputFormat
: OutputFormat.TEXT
: (outputFormat as OutputFormat);
const includePartialMessages = Boolean(argv.includePartialMessages);
// Determine approval mode with backward compatibility
let approvalMode: ApprovalMode;
@@ -629,11 +687,31 @@ export async function loadCliConfig(
throw err;
}
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
// Interactive mode determination with priority:
// 1. If promptInteractive (-i flag) is provided, it is explicitly interactive
// 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive
// 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive
const hasQuery = !!argv.query;
const interactive =
!!argv.promptInteractive ||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
const hasPrompt = !!argv.prompt;
let interactive: boolean;
if (argv.promptInteractive) {
// Priority 1: Explicit -i flag means interactive
interactive = true;
} else if (
(outputFormat === OutputFormat.STREAM_JSON ||
outputFormat === OutputFormat.JSON) &&
(hasQuery || hasPrompt)
) {
// Priority 2: JSON/stream-json output with query/prompt means non-interactive
interactive = false;
} else if (!hasQuery && !hasPrompt) {
// Priority 3: No query or prompt means interactive only if TTY (format arguments ignored)
interactive = process.stdin.isTTY ?? false;
} else {
// Default: If we have query/prompt but output format is TEXT, assume non-interactive
// (fallback for edge cases where query/prompt is provided with TEXT output)
interactive = false;
}
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive && !argv.experimentalAcp) {
@@ -755,6 +833,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,
@@ -798,7 +879,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

@@ -483,6 +483,27 @@ export class LoadedSettings {
}
}
/**
* Creates a minimal LoadedSettings instance with empty settings.
* Used in stream-json mode where settings are ignored.
*/
export function createMinimalSettings(): LoadedSettings {
const emptySettingsFile: SettingsFile = {
path: '',
settings: {},
originalSettings: {},
rawJson: '{}',
};
return new LoadedSettings(
emptySettingsFile,
emptySettingsFile,
emptySettingsFile,
emptySettingsFile,
false,
new Set(),
);
}
function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {

View File

@@ -22,6 +22,7 @@ import {
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { OutputFormat } from '@qwen-code/qwen-code-core';
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
@@ -158,6 +159,7 @@ describe('gemini.tsx main function', () => {
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
} as unknown as Config;
});
vi.mocked(loadSettings).mockReturnValue({
@@ -230,6 +232,143 @@ describe('gemini.tsx main function', () => {
// Avoid the process.exit error from being thrown.
processExitSpy.mockRestore();
});
it('invokes runNonInteractiveStreamJson and performs cleanup in stream-json mode', async () => {
const originalIsTTY = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY',
);
const originalIsRaw = Object.getOwnPropertyDescriptor(
process.stdin,
'isRaw',
);
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
Object.defineProperty(process.stdin, 'isRaw', {
value: false,
configurable: true,
});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const extensionModule = await import('./config/extension.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const streamJsonModule = await import('./nonInteractive/session.js');
const initializerModule = await import('./core/initializer.js');
const startupWarningsModule = await import('./utils/startupWarnings.js');
const userStartupWarningsModule = await import(
'./utils/userStartupWarnings.js'
);
vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
runExitCleanupMock.mockResolvedValue(undefined);
vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]);
vi.spyOn(
extensionModule.ExtensionStorage,
'getUserExtensionsDir',
).mockReturnValue('/tmp/extensions');
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
});
vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]);
vi.spyOn(
userStartupWarningsModule,
'getUserStartupWarnings',
).mockResolvedValue([]);
const validatedConfig = { validated: true } as unknown as Config;
const validateAuthSpy = vi
.spyOn(validatorModule, 'validateNonInteractiveAuth')
.mockResolvedValue(validatedConfig);
const runStreamJsonSpy = vi
.spyOn(streamJsonModule, 'runNonInteractiveStreamJson')
.mockResolvedValue(undefined);
vi.mocked(loadSettings).mockReturnValue({
errors: [],
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
} as never);
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as never);
const configStub = {
isInteractive: () => false,
getQuestion: () => ' hello stream ',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
initialize: vi.fn().mockResolvedValue(undefined),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getInputFormat: () => 'stream-json',
getContentGeneratorConfig: () => ({ authType: 'test-auth' }),
} as unknown as Config;
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
process.env['SANDBOX'] = '1';
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) {
throw error;
}
} finally {
processExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
if (originalIsRaw) {
Object.defineProperty(process.stdin, 'isRaw', originalIsRaw);
} else {
delete (process.stdin as { isRaw?: unknown }).isRaw;
}
delete process.env['SANDBOX'];
}
expect(runStreamJsonSpy).toHaveBeenCalledTimes(1);
const [configArg, inputArg] = runStreamJsonSpy.mock.calls[0];
expect(configArg).toBe(validatedConfig);
expect(inputArg).toBe('hello stream');
expect(validateAuthSpy).toHaveBeenCalledWith(
undefined,
undefined,
configStub,
expect.any(Object),
);
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
});
});
describe('gemini.tsx main function kitty protocol', () => {
@@ -337,7 +476,9 @@ describe('gemini.tsx main function kitty protocol', () => {
screenReader: undefined,
vlmSwitchMode: undefined,
useSmartEdit: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
});
await main();
@@ -412,6 +553,7 @@ describe('startInteractiveUI', () => {
vi.mock('./utils/cleanup.js', () => ({
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
registerCleanup: vi.fn(),
runExitCleanup: vi.fn(() => Promise.resolve()),
}));
vi.mock('ink', () => ({

View File

@@ -4,58 +4,60 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type { Config } from '@qwen-code/qwen-code-core';
import {
AuthType,
getOauthClient,
InputFormat,
logUserPrompt,
} from '@qwen-code/qwen-code-core';
import { render } from 'ink';
import { AppContainer } from './ui/AppContainer.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import * as cliConfig from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { randomUUID } from 'node:crypto';
import dns from 'node:dns';
import os from 'node:os';
import { basename } from 'node:path';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import { randomUUID } from 'node:crypto';
import { start_sandbox } from './utils/sandbox.js';
import React from 'react';
import { validateAuthMethod } from './config/auth.js';
import * as cliConfig from './config/config.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { ExtensionStorage, loadExtensions } from './config/extension.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { loadSettings, migrateDeprecatedSettings } from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import {
initializeApp,
type InitializationResult,
} from './core/initializer.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import { ExtensionStorage, loadExtensions } from './config/extension.js';
import { runNonInteractiveStreamJson } from './nonInteractive/session.js';
import { AppContainer } from './ui/AppContainer.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import {
cleanupCheckpoints,
registerCleanup,
runExitCleanup,
} from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
AuthType,
getOauthClient,
logUserPrompt,
} from '@qwen-code/qwen-code-core';
import {
initializeApp,
type InitializationResult,
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { AppEvent, appEvents } from './utils/events.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { appEvents, AppEvent } from './utils/events.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { readStdin } from './utils/readStdin.js';
import {
relaunchOnExitCode,
relaunchAppInChildProcess,
relaunchOnExitCode,
} from './utils/relaunch.js';
import { start_sandbox } from './utils/sandbox.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { getCliVersion } from './utils/version.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
export function validateDnsResolutionOrder(
@@ -106,9 +108,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
return [];
}
import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
export function setupUnhandledRejectionHandler() {
let unhandledRejectionOccurred = false;
@@ -218,12 +220,6 @@ export async function main() {
}
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: isDebugMode,
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder),
@@ -348,6 +344,15 @@ export async function main() {
process.exit(0);
}
// Setup unified ConsolePatcher based on interactive mode
const isInteractive = config.isInteractive();
const consolePatcher = new ConsolePatcher({
stderr: isInteractive,
debugMode: isDebugMode,
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
const wasRaw = process.stdin.isRaw;
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
@@ -410,14 +415,43 @@ export async function main() {
await config.initialize();
// If not a TTY, read from stdin
// This is for cases where the user pipes input directly into the command
if (!process.stdin.isTTY) {
// Check input format BEFORE reading stdin
// In STREAM_JSON mode, stdin should be left for StreamJsonInputReader
const inputFormat =
typeof config.getInputFormat === 'function'
? config.getInputFormat()
: InputFormat.TEXT;
// Only read stdin if NOT in stream-json mode
// In stream-json mode, stdin is used for protocol messages (control requests, etc.)
// and should be consumed by StreamJsonInputReader instead
if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) {
const stdinData = await readStdin();
if (stdinData) {
input = `${stdinData}\n\n${input}`;
}
}
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
config,
settings,
);
const prompt_id = Math.random().toString(16).slice(2);
if (inputFormat === InputFormat.STREAM_JSON) {
const trimmedInput = (input ?? '').trim();
await runNonInteractiveStreamJson(
nonInteractiveConfig,
trimmedInput.length > 0 ? trimmedInput : '',
);
await runExitCleanup();
process.exit(0);
}
if (!input) {
console.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
@@ -425,7 +459,6 @@ export async function main() {
process.exit(1);
}
const prompt_id = Math.random().toString(16).slice(2);
logUserPrompt(config, {
'event.name': 'user_prompt',
'event.timestamp': new Date().toISOString(),
@@ -435,13 +468,6 @@ export async function main() {
prompt_length: input.length,
});
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
config,
settings,
);
if (config.getDebugMode()) {
console.log('Session ID: %s', sessionId);
}

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Control Context
*
* Layer 1 of the control plane architecture. Provides shared, session-scoped
* state for all controllers and services, eliminating the need for prop
* drilling. Mutable fields are intentionally exposed so controllers can track
* runtime state (e.g. permission mode, active MCP clients).
*/
import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
import type { PermissionMode } from '../types.js';
/**
* Control Context interface
*
* Provides shared access to session-scoped resources and mutable state
* for all controllers across both ControlDispatcher (protocol routing) and
* ControlService (programmatic API).
*/
export interface IControlContext {
readonly config: Config;
readonly streamJson: StreamJsonOutputAdapter;
readonly sessionId: string;
readonly abortSignal: AbortSignal;
readonly debugMode: boolean;
permissionMode: PermissionMode;
sdkMcpServers: Set<string>;
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
onInterrupt?: () => void;
}
/**
* Control Context implementation
*/
export class ControlContext implements IControlContext {
readonly config: Config;
readonly streamJson: StreamJsonOutputAdapter;
readonly sessionId: string;
readonly abortSignal: AbortSignal;
readonly debugMode: boolean;
permissionMode: PermissionMode;
sdkMcpServers: Set<string>;
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
onInterrupt?: () => void;
constructor(options: {
config: Config;
streamJson: StreamJsonOutputAdapter;
sessionId: string;
abortSignal: AbortSignal;
permissionMode?: PermissionMode;
onInterrupt?: () => void;
}) {
this.config = options.config;
this.streamJson = options.streamJson;
this.sessionId = options.sessionId;
this.abortSignal = options.abortSignal;
this.debugMode = options.config.getDebugMode();
this.permissionMode = options.permissionMode || 'default';
this.sdkMcpServers = new Set();
this.mcpClients = new Map();
this.onInterrupt = options.onInterrupt;
}
}

View File

@@ -0,0 +1,924 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ControlDispatcher } from './ControlDispatcher.js';
import type { IControlContext } from './ControlContext.js';
import type { SystemController } from './controllers/systemController.js';
import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js';
import type {
CLIControlRequest,
CLIControlResponse,
ControlResponse,
ControlRequestPayload,
CLIControlInitializeRequest,
CLIControlInterruptRequest,
CLIControlSetModelRequest,
CLIControlSupportedCommandsRequest,
} from '../types.js';
/**
* Creates a mock control context for testing
*/
function createMockContext(debugMode: boolean = false): IControlContext {
const abortController = new AbortController();
const mockStreamJson = {
send: vi.fn(),
} as unknown as StreamJsonOutputAdapter;
const mockConfig = {
getDebugMode: vi.fn().mockReturnValue(debugMode),
};
return {
config: mockConfig as unknown as IControlContext['config'],
streamJson: mockStreamJson,
sessionId: 'test-session-id',
abortSignal: abortController.signal,
debugMode,
permissionMode: 'default',
sdkMcpServers: new Set<string>(),
mcpClients: new Map(),
};
}
/**
* Creates a mock system controller for testing
*/
function createMockSystemController() {
return {
handleRequest: vi.fn(),
sendControlRequest: vi.fn(),
cleanup: vi.fn(),
} as unknown as SystemController;
}
describe('ControlDispatcher', () => {
let dispatcher: ControlDispatcher;
let mockContext: IControlContext;
let mockSystemController: SystemController;
beforeEach(() => {
mockContext = createMockContext();
mockSystemController = createMockSystemController();
// Mock SystemController constructor
vi.doMock('./controllers/systemController.js', () => ({
SystemController: vi.fn().mockImplementation(() => mockSystemController),
}));
dispatcher = new ControlDispatcher(mockContext);
// Replace with mock controller for easier testing
(
dispatcher as unknown as { systemController: SystemController }
).systemController = mockSystemController;
});
describe('constructor', () => {
it('should initialize with context and create controllers', () => {
expect(dispatcher).toBeDefined();
expect(dispatcher.systemController).toBeDefined();
});
it('should listen to abort signal and shutdown when aborted', () => {
const abortController = new AbortController();
const context = {
...createMockContext(),
abortSignal: abortController.signal,
};
const newDispatcher = new ControlDispatcher(context);
vi.spyOn(newDispatcher, 'shutdown');
abortController.abort();
// Give event loop a chance to process
return new Promise<void>((resolve) => {
setImmediate(() => {
expect(newDispatcher.shutdown).toHaveBeenCalled();
resolve();
});
});
});
});
describe('dispatch', () => {
it('should route initialize request to system controller', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-1',
request: {
subtype: 'initialize',
} as CLIControlInitializeRequest,
};
const mockResponse = {
subtype: 'initialize',
capabilities: { test: true },
};
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
mockResponse,
);
await dispatcher.dispatch(request);
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
request.request,
'req-1',
);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-1',
response: mockResponse,
},
});
});
it('should route interrupt request to system controller', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-2',
request: {
subtype: 'interrupt',
} as CLIControlInterruptRequest,
};
const mockResponse = { subtype: 'interrupt' };
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
mockResponse,
);
await dispatcher.dispatch(request);
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
request.request,
'req-2',
);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-2',
response: mockResponse,
},
});
});
it('should route set_model request to system controller', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-3',
request: {
subtype: 'set_model',
model: 'test-model',
} as CLIControlSetModelRequest,
};
const mockResponse = {
subtype: 'set_model',
model: 'test-model',
};
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
mockResponse,
);
await dispatcher.dispatch(request);
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
request.request,
'req-3',
);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-3',
response: mockResponse,
},
});
});
it('should route supported_commands request to system controller', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-4',
request: {
subtype: 'supported_commands',
} as CLIControlSupportedCommandsRequest,
};
const mockResponse = {
subtype: 'supported_commands',
commands: ['initialize', 'interrupt'],
};
vi.mocked(mockSystemController.handleRequest).mockResolvedValue(
mockResponse,
);
await dispatcher.dispatch(request);
expect(mockSystemController.handleRequest).toHaveBeenCalledWith(
request.request,
'req-4',
);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-4',
response: mockResponse,
},
});
});
it('should send error response when controller throws error', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-5',
request: {
subtype: 'initialize',
} as CLIControlInitializeRequest,
};
const error = new Error('Test error');
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(error);
await dispatcher.dispatch(request);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-5',
error: 'Test error',
},
});
});
it('should handle non-Error thrown values', async () => {
const request: CLIControlRequest = {
type: 'control_request',
request_id: 'req-6',
request: {
subtype: 'initialize',
} as CLIControlInitializeRequest,
};
vi.mocked(mockSystemController.handleRequest).mockRejectedValue(
'String error',
);
await dispatcher.dispatch(request);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-6',
error: 'String error',
},
});
});
it('should send error response for unknown request subtype', async () => {
const request = {
type: 'control_request' as const,
request_id: 'req-7',
request: {
subtype: 'unknown_subtype',
} as unknown as ControlRequestPayload,
};
await dispatcher.dispatch(request);
// Dispatch catches errors and sends error response instead of throwing
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: 'req-7',
error: 'Unknown control request subtype: unknown_subtype',
},
});
});
});
describe('handleControlResponse', () => {
it('should resolve pending outgoing request on success response', () => {
const requestId = 'outgoing-req-1';
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: { result: 'success' },
},
};
// Register a pending outgoing request
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
// Access private method through type casting
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
dispatcher.handleControlResponse(response);
expect(resolve).toHaveBeenCalledWith(response.response);
expect(reject).not.toHaveBeenCalled();
});
it('should reject pending outgoing request on error response', () => {
const requestId = 'outgoing-req-2';
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error: 'Request failed',
},
};
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
dispatcher.handleControlResponse(response);
expect(reject).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Request failed',
}),
);
expect(resolve).not.toHaveBeenCalled();
});
it('should handle error object in error response', () => {
const requestId = 'outgoing-req-3';
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error: { message: 'Detailed error', code: 500 },
},
};
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
dispatcher.handleControlResponse(response);
expect(reject).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Detailed error',
}),
);
});
it('should handle response for non-existent pending request gracefully', () => {
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: 'non-existent',
response: {},
},
};
// Should not throw
expect(() => dispatcher.handleControlResponse(response)).not.toThrow();
});
it('should handle response for non-existent request in debug mode', () => {
const context = createMockContext(true);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const dispatcherWithDebug = new ControlDispatcher(context);
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: 'non-existent',
response: {},
},
};
dispatcherWithDebug.handleControlResponse(response);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[ControlDispatcher] No pending outgoing request for: non-existent',
),
);
consoleSpy.mockRestore();
});
});
describe('sendControlRequest', () => {
it('should delegate to system controller sendControlRequest', async () => {
const payload: ControlRequestPayload = {
subtype: 'initialize',
} as CLIControlInitializeRequest;
const expectedResponse: ControlResponse = {
subtype: 'success',
request_id: 'test-id',
response: {},
};
vi.mocked(mockSystemController.sendControlRequest).mockResolvedValue(
expectedResponse,
);
const result = await dispatcher.sendControlRequest(payload, 5000);
expect(mockSystemController.sendControlRequest).toHaveBeenCalledWith(
payload,
5000,
);
expect(result).toBe(expectedResponse);
});
});
describe('handleCancel', () => {
it('should cancel specific incoming request', () => {
const requestId = 'cancel-req-1';
const abortController = new AbortController();
const timeoutId = setTimeout(() => {}, 1000);
const abortSpy = vi.spyOn(abortController, 'abort');
(
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest(
requestId,
'SystemController',
abortController,
timeoutId,
);
dispatcher.handleCancel(requestId);
expect(abortSpy).toHaveBeenCalled();
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error: 'Request cancelled',
},
});
});
it('should cancel all incoming requests when no requestId provided', () => {
const requestId1 = 'cancel-req-2';
const requestId2 = 'cancel-req-3';
const abortController1 = new AbortController();
const abortController2 = new AbortController();
const timeoutId1 = setTimeout(() => {}, 1000);
const timeoutId2 = setTimeout(() => {}, 1000);
const abortSpy1 = vi.spyOn(abortController1, 'abort');
const abortSpy2 = vi.spyOn(abortController2, 'abort');
const register = (
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest.bind(dispatcher);
register(requestId1, 'SystemController', abortController1, timeoutId1);
register(requestId2, 'SystemController', abortController2, timeoutId2);
dispatcher.handleCancel();
expect(abortSpy1).toHaveBeenCalled();
expect(abortSpy2).toHaveBeenCalled();
expect(mockContext.streamJson.send).toHaveBeenCalledTimes(2);
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId1,
error: 'All requests cancelled',
},
});
expect(mockContext.streamJson.send).toHaveBeenCalledWith({
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId2,
error: 'All requests cancelled',
},
});
});
it('should handle cancel of non-existent request gracefully', () => {
expect(() => dispatcher.handleCancel('non-existent')).not.toThrow();
});
it('should log cancellation in debug mode', () => {
const context = createMockContext(true);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const dispatcherWithDebug = new ControlDispatcher(context);
const requestId = 'cancel-req-debug';
const abortController = new AbortController();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcherWithDebug as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest(
requestId,
'SystemController',
abortController,
timeoutId,
);
dispatcherWithDebug.handleCancel(requestId);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[ControlDispatcher] Cancelled incoming request: cancel-req-debug',
),
);
consoleSpy.mockRestore();
});
});
describe('shutdown', () => {
it('should cancel all pending incoming requests', () => {
const requestId1 = 'shutdown-req-1';
const requestId2 = 'shutdown-req-2';
const abortController1 = new AbortController();
const abortController2 = new AbortController();
const timeoutId1 = setTimeout(() => {}, 1000);
const timeoutId2 = setTimeout(() => {}, 1000);
const abortSpy1 = vi.spyOn(abortController1, 'abort');
const abortSpy2 = vi.spyOn(abortController2, 'abort');
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const register = (
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest.bind(dispatcher);
register(requestId1, 'SystemController', abortController1, timeoutId1);
register(requestId2, 'SystemController', abortController2, timeoutId2);
dispatcher.shutdown();
expect(abortSpy1).toHaveBeenCalled();
expect(abortSpy2).toHaveBeenCalled();
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
});
it('should reject all pending outgoing requests', () => {
const requestId1 = 'outgoing-shutdown-1';
const requestId2 = 'outgoing-shutdown-2';
const reject1 = vi.fn();
const reject2 = vi.fn();
const timeoutId1 = setTimeout(() => {}, 1000);
const timeoutId2 = setTimeout(() => {}, 1000);
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const register = (
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest.bind(dispatcher);
register(requestId1, 'SystemController', vi.fn(), reject1, timeoutId1);
register(requestId2, 'SystemController', vi.fn(), reject2, timeoutId2);
dispatcher.shutdown();
expect(reject1).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Dispatcher shutdown',
}),
);
expect(reject2).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Dispatcher shutdown',
}),
);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
});
it('should cleanup all controllers', () => {
vi.mocked(mockSystemController.cleanup).mockImplementation(() => {});
dispatcher.shutdown();
expect(mockSystemController.cleanup).toHaveBeenCalled();
});
it('should log shutdown in debug mode', () => {
const context = createMockContext(true);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const dispatcherWithDebug = new ControlDispatcher(context);
dispatcherWithDebug.shutdown();
expect(consoleSpy).toHaveBeenCalledWith(
'[ControlDispatcher] Shutting down',
);
consoleSpy.mockRestore();
});
});
describe('pending request registry', () => {
describe('registerIncomingRequest', () => {
it('should register incoming request', () => {
const requestId = 'reg-incoming-1';
const abortController = new AbortController();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerIncomingRequest(
requestId,
'SystemController',
abortController,
timeoutId,
);
// Verify it was registered by trying to cancel it
dispatcher.handleCancel(requestId);
expect(abortController.signal.aborted).toBe(true);
});
});
describe('deregisterIncomingRequest', () => {
it('should deregister incoming request', () => {
const requestId = 'dereg-incoming-1';
const abortController = new AbortController();
const timeoutId = setTimeout(() => {}, 1000);
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
(
dispatcher as unknown as {
registerIncomingRequest: (
id: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
) => void;
deregisterIncomingRequest: (id: string) => void;
}
).registerIncomingRequest(
requestId,
'SystemController',
abortController,
timeoutId,
);
(
dispatcher as unknown as {
deregisterIncomingRequest: (id: string) => void;
}
).deregisterIncomingRequest(requestId);
// Verify it was deregistered - cancel should not find it
const sendMock = vi.mocked(mockContext.streamJson.send);
const sendCallCount = sendMock.mock.calls.length;
dispatcher.handleCancel(requestId);
// Should not send cancel response for non-existent request
expect(sendMock.mock.calls.length).toBe(sendCallCount);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
});
it('should handle deregister of non-existent request gracefully', () => {
expect(() => {
(
dispatcher as unknown as {
deregisterIncomingRequest: (id: string) => void;
}
).deregisterIncomingRequest('non-existent');
}).not.toThrow();
});
});
describe('registerOutgoingRequest', () => {
it('should register outgoing request', () => {
const requestId = 'reg-outgoing-1';
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
// Verify it was registered by handling a response
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {},
},
};
dispatcher.handleControlResponse(response);
expect(resolve).toHaveBeenCalled();
});
});
describe('deregisterOutgoingRequest', () => {
it('should deregister outgoing request', () => {
const requestId = 'dereg-outgoing-1';
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (r: ControlResponse) => void,
reject: (e: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
deregisterOutgoingRequest: (id: string) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
(
dispatcher as unknown as {
deregisterOutgoingRequest: (id: string) => void;
}
).deregisterOutgoingRequest(requestId);
// Verify it was deregistered - response should not find it
const response: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {},
},
};
dispatcher.handleControlResponse(response);
expect(resolve).not.toHaveBeenCalled();
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
});
it('should handle deregister of non-existent request gracefully', () => {
expect(() => {
(
dispatcher as unknown as {
deregisterOutgoingRequest: (id: string) => void;
}
).deregisterOutgoingRequest('non-existent');
}).not.toThrow();
});
});
});
});

View File

@@ -0,0 +1,353 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Control Dispatcher
*
* Layer 2 of the control plane architecture. Routes control requests between
* SDK and CLI to appropriate controllers, manages pending request registries,
* and handles cancellation/cleanup. Application code MUST NOT depend on
* controller instances exposed by this class; instead, use ControlService,
* which wraps these controllers with a stable programmatic API.
*
* Controllers:
* - SystemController: initialize, interrupt, set_model, supported_commands
* - PermissionController: can_use_tool, set_permission_mode
* - MCPController: mcp_message, mcp_server_status
* - HookController: hook_callback
*
* Note: Control request types are centrally defined in the ControlRequestType
* enum in packages/sdk/typescript/src/types/controlRequests.ts
*/
import type { IControlContext } from './ControlContext.js';
import type { IPendingRequestRegistry } from './controllers/baseController.js';
import { SystemController } from './controllers/systemController.js';
// import { PermissionController } from './controllers/permissionController.js';
// import { MCPController } from './controllers/mcpController.js';
// import { HookController } from './controllers/hookController.js';
import type {
CLIControlRequest,
CLIControlResponse,
ControlResponse,
ControlRequestPayload,
} from '../types.js';
/**
* Tracks an incoming request from SDK awaiting CLI response
*/
interface PendingIncomingRequest {
controller: string;
abortController: AbortController;
timeoutId: NodeJS.Timeout;
}
/**
* Tracks an outgoing request from CLI awaiting SDK response
*/
interface PendingOutgoingRequest {
controller: string;
resolve: (response: ControlResponse) => void;
reject: (error: Error) => void;
timeoutId: NodeJS.Timeout;
}
/**
* Central coordinator for control plane communication.
* Routes requests to controllers and manages request lifecycle.
*/
export class ControlDispatcher implements IPendingRequestRegistry {
private context: IControlContext;
// Make controllers publicly accessible
readonly systemController: SystemController;
// readonly permissionController: PermissionController;
// readonly mcpController: MCPController;
// readonly hookController: HookController;
// Central pending request registries
private pendingIncomingRequests: Map<string, PendingIncomingRequest> =
new Map();
private pendingOutgoingRequests: Map<string, PendingOutgoingRequest> =
new Map();
constructor(context: IControlContext) {
this.context = context;
// Create domain controllers with context and registry
this.systemController = new SystemController(
context,
this,
'SystemController',
);
// this.permissionController = new PermissionController(
// context,
// this,
// 'PermissionController',
// );
// this.mcpController = new MCPController(context, this, 'MCPController');
// this.hookController = new HookController(context, this, 'HookController');
// Listen for main abort signal
this.context.abortSignal.addEventListener('abort', () => {
this.shutdown();
});
}
/**
* Routes an incoming request to the appropriate controller and sends response
*/
async dispatch(request: CLIControlRequest): Promise<void> {
const { request_id, request: payload } = request;
try {
// Route to appropriate controller
const controller = this.getControllerForRequest(payload.subtype);
const response = await controller.handleRequest(payload, request_id);
// Send success response
this.sendSuccessResponse(request_id, response);
} catch (error) {
// Send error response
const errorMessage =
error instanceof Error ? error.message : String(error);
this.sendErrorResponse(request_id, errorMessage);
}
}
/**
* Processes response from SDK for an outgoing request
*/
handleControlResponse(response: CLIControlResponse): void {
const responsePayload = response.response;
const requestId = responsePayload.request_id;
const pending = this.pendingOutgoingRequests.get(requestId);
if (!pending) {
// No pending request found - may have timed out or been cancelled
if (this.context.debugMode) {
console.error(
`[ControlDispatcher] No pending outgoing request for: ${requestId}`,
);
}
return;
}
// Deregister
this.deregisterOutgoingRequest(requestId);
// Resolve or reject based on response type
if (responsePayload.subtype === 'success') {
pending.resolve(responsePayload);
} else {
const errorMessage =
typeof responsePayload.error === 'string'
? responsePayload.error
: (responsePayload.error?.message ?? 'Unknown error');
pending.reject(new Error(errorMessage));
}
}
/**
* Sends a control request to SDK and waits for response
*/
async sendControlRequest(
payload: ControlRequestPayload,
timeoutMs?: number,
): Promise<ControlResponse> {
// Delegate to system controller (or any controller, they all have the same method)
return this.systemController.sendControlRequest(payload, timeoutMs);
}
/**
* Cancels a specific request or all pending requests
*/
handleCancel(requestId?: string): void {
if (requestId) {
// Cancel specific incoming request
const pending = this.pendingIncomingRequests.get(requestId);
if (pending) {
pending.abortController.abort();
this.deregisterIncomingRequest(requestId);
this.sendErrorResponse(requestId, 'Request cancelled');
if (this.context.debugMode) {
console.error(
`[ControlDispatcher] Cancelled incoming request: ${requestId}`,
);
}
}
} else {
// Cancel ALL pending incoming requests
const requestIds = Array.from(this.pendingIncomingRequests.keys());
for (const id of requestIds) {
const pending = this.pendingIncomingRequests.get(id);
if (pending) {
pending.abortController.abort();
this.deregisterIncomingRequest(id);
this.sendErrorResponse(id, 'All requests cancelled');
}
}
if (this.context.debugMode) {
console.error(
`[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`,
);
}
}
}
/**
* Stops all pending requests and cleans up all controllers
*/
shutdown(): void {
if (this.context.debugMode) {
console.error('[ControlDispatcher] Shutting down');
}
// Cancel all incoming requests
for (const [
_requestId,
pending,
] of this.pendingIncomingRequests.entries()) {
pending.abortController.abort();
clearTimeout(pending.timeoutId);
}
this.pendingIncomingRequests.clear();
// Cancel all outgoing requests
for (const [
_requestId,
pending,
] of this.pendingOutgoingRequests.entries()) {
clearTimeout(pending.timeoutId);
pending.reject(new Error('Dispatcher shutdown'));
}
this.pendingOutgoingRequests.clear();
// Cleanup controllers (MCP controller will close all clients)
this.systemController.cleanup();
// this.permissionController.cleanup();
// this.mcpController.cleanup();
// this.hookController.cleanup();
}
/**
* Registers an incoming request in the pending registry
*/
registerIncomingRequest(
requestId: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
): void {
this.pendingIncomingRequests.set(requestId, {
controller,
abortController,
timeoutId,
});
}
/**
* Removes an incoming request from the pending registry
*/
deregisterIncomingRequest(requestId: string): void {
const pending = this.pendingIncomingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingIncomingRequests.delete(requestId);
}
}
/**
* Registers an outgoing request in the pending registry
*/
registerOutgoingRequest(
requestId: string,
controller: string,
resolve: (response: ControlResponse) => void,
reject: (error: Error) => void,
timeoutId: NodeJS.Timeout,
): void {
this.pendingOutgoingRequests.set(requestId, {
controller,
resolve,
reject,
timeoutId,
});
}
/**
* Removes an outgoing request from the pending registry
*/
deregisterOutgoingRequest(requestId: string): void {
const pending = this.pendingOutgoingRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingOutgoingRequests.delete(requestId);
}
}
/**
* Returns the controller that handles the given request subtype
*/
private getControllerForRequest(subtype: string) {
switch (subtype) {
case 'initialize':
case 'interrupt':
case 'set_model':
case 'supported_commands':
return this.systemController;
// case 'can_use_tool':
// case 'set_permission_mode':
// return this.permissionController;
// case 'mcp_message':
// case 'mcp_server_status':
// return this.mcpController;
// case 'hook_callback':
// return this.hookController;
default:
throw new Error(`Unknown control request subtype: ${subtype}`);
}
}
/**
* Sends a success response back to SDK
*/
private sendSuccessResponse(
requestId: string,
response: Record<string, unknown>,
): void {
const controlResponse: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response,
},
};
this.context.streamJson.send(controlResponse);
}
/**
* Sends an error response back to SDK
*/
private sendErrorResponse(requestId: string, error: string): void {
const controlResponse: CLIControlResponse = {
type: 'control_response',
response: {
subtype: 'error',
request_id: requestId,
error,
},
};
this.context.streamJson.send(controlResponse);
}
}

View File

@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Control Service - Public Programmatic API
*
* Provides type-safe access to control plane functionality for internal
* CLI code. This is the ONLY programmatic interface that should be used by:
* - nonInteractiveCli
* - Session managers
* - Tool execution handlers
* - Internal CLI logic
*
* DO NOT use ControlDispatcher or controllers directly from application code.
*
* Architecture:
* - ControlContext stores shared session state (Layer 1)
* - ControlDispatcher handles protocol-level routing (Layer 2)
* - ControlService provides programmatic API for internal CLI usage (Layer 3)
*
* ControlService and ControlDispatcher share controller instances to ensure
* a single source of truth. All higher level code MUST access the control
* plane exclusively through ControlService.
*/
import type { IControlContext } from './ControlContext.js';
import type { ControlDispatcher } from './ControlDispatcher.js';
import type {
// PermissionServiceAPI,
SystemServiceAPI,
// McpServiceAPI,
// HookServiceAPI,
} from './types/serviceAPIs.js';
/**
* Control Service
*
* Facade layer providing domain-grouped APIs for control plane operations.
* Shares controller instances with ControlDispatcher to ensure single source
* of truth and state consistency.
*/
export class ControlService {
private dispatcher: ControlDispatcher;
/**
* Construct ControlService
*
* @param context - Control context (unused directly, passed to dispatcher)
* @param dispatcher - Control dispatcher that owns the controller instances
*/
constructor(context: IControlContext, dispatcher: ControlDispatcher) {
this.dispatcher = dispatcher;
}
/**
* Permission Domain API
*
* Handles tool execution permissions, approval checks, and callbacks.
* Delegates to the shared PermissionController instance.
*/
// get permission(): PermissionServiceAPI {
// const controller = this.dispatcher.permissionController;
// return {
// /**
// * Check if a tool should be allowed based on current permission settings
// *
// * Evaluates permission mode and tool registry to determine if execution
// * should proceed. Can optionally modify tool arguments based on confirmation details.
// *
// * @param toolRequest - Tool call request information
// * @param confirmationDetails - Optional confirmation details for UI
// * @returns Permission decision with optional updated arguments
// */
// shouldAllowTool: controller.shouldAllowTool.bind(controller),
//
// /**
// * Build UI suggestions for tool confirmation dialogs
// *
// * Creates actionable permission suggestions based on tool confirmation details.
// *
// * @param confirmationDetails - Tool confirmation details
// * @returns Array of permission suggestions or null
// */
// buildPermissionSuggestions:
// controller.buildPermissionSuggestions.bind(controller),
//
// /**
// * Get callback for monitoring tool call status updates
// *
// * Returns callback function for integration with CoreToolScheduler.
// *
// * @returns Callback function for tool call updates
// */
// getToolCallUpdateCallback:
// controller.getToolCallUpdateCallback.bind(controller),
// };
// }
/**
* System Domain API
*
* Handles system-level operations and session management.
* Delegates to the shared SystemController instance.
*/
get system(): SystemServiceAPI {
const controller = this.dispatcher.systemController;
return {
/**
* Get control capabilities
*
* Returns the control capabilities object indicating what control
* features are available. Used exclusively for the initialize
* control response. System messages do not include capabilities.
*
* @returns Control capabilities object
*/
getControlCapabilities: () => controller.buildControlCapabilities(),
};
}
/**
* MCP Domain API
*
* Handles Model Context Protocol server interactions.
* Delegates to the shared MCPController instance.
*/
// get mcp(): McpServiceAPI {
// return {
// /**
// * Get or create MCP client for a server (lazy initialization)
// *
// * Returns existing client or creates new connection.
// *
// * @param serverName - Name of the MCP server
// * @returns Promise with client and config
// */
// getMcpClient: async (serverName: string) => {
// // MCPController has a private method getOrCreateMcpClient
// // We need to expose it via the API
// // For now, throw error as placeholder
// // The actual implementation will be added when we update MCPController
// throw new Error(
// `getMcpClient not yet implemented in ControlService. Server: ${serverName}`,
// );
// },
//
// /**
// * List all available MCP servers
// *
// * Returns names of configured/connected MCP servers.
// *
// * @returns Array of server names
// */
// listServers: () => {
// // Get servers from context
// const sdkServers = Array.from(
// this.dispatcher.mcpController['context'].sdkMcpServers,
// );
// const cliServers = Array.from(
// this.dispatcher.mcpController['context'].mcpClients.keys(),
// );
// return [...new Set([...sdkServers, ...cliServers])];
// },
// };
// }
/**
* Hook Domain API
*
* Handles hook callback processing (placeholder for future expansion).
* Delegates to the shared HookController instance.
*/
// get hook(): HookServiceAPI {
// // HookController has no public methods yet - controller access reserved for future use
// return {};
// }
/**
* Cleanup all controllers
*
* Should be called on session shutdown. Delegates to dispatcher's shutdown
* method to ensure all controllers are properly cleaned up.
*/
cleanup(): void {
// Delegate to dispatcher which manages controller cleanup
this.dispatcher.shutdown();
}
}

View File

@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Base Controller
*
* Abstract base class for domain-specific control plane controllers.
* Provides common functionality for:
* - Handling incoming control requests (SDK -> CLI)
* - Sending outgoing control requests (CLI -> SDK)
* - Request lifecycle management with timeout and cancellation
* - Integration with central pending request registry
*/
import { randomUUID } from 'node:crypto';
import type { IControlContext } from '../ControlContext.js';
import type {
ControlRequestPayload,
ControlResponse,
CLIControlRequest,
} from '../../types.js';
const DEFAULT_REQUEST_TIMEOUT_MS = 30000; // 30 seconds
/**
* Registry interface for controllers to register/deregister pending requests
*/
export interface IPendingRequestRegistry {
registerIncomingRequest(
requestId: string,
controller: string,
abortController: AbortController,
timeoutId: NodeJS.Timeout,
): void;
deregisterIncomingRequest(requestId: string): void;
registerOutgoingRequest(
requestId: string,
controller: string,
resolve: (response: ControlResponse) => void,
reject: (error: Error) => void,
timeoutId: NodeJS.Timeout,
): void;
deregisterOutgoingRequest(requestId: string): void;
}
/**
* Abstract base controller class
*
* Subclasses should implement handleRequestPayload() to process specific
* control request types.
*/
export abstract class BaseController {
protected context: IControlContext;
protected registry: IPendingRequestRegistry;
protected controllerName: string;
constructor(
context: IControlContext,
registry: IPendingRequestRegistry,
controllerName: string,
) {
this.context = context;
this.registry = registry;
this.controllerName = controllerName;
}
/**
* Handle an incoming control request
*
* Manages lifecycle: register -> process -> deregister
*/
async handleRequest(
payload: ControlRequestPayload,
requestId: string,
): Promise<Record<string, unknown>> {
const requestAbortController = new AbortController();
// Setup timeout
const timeoutId = setTimeout(() => {
requestAbortController.abort();
this.registry.deregisterIncomingRequest(requestId);
if (this.context.debugMode) {
console.error(`[${this.controllerName}] Request timeout: ${requestId}`);
}
}, DEFAULT_REQUEST_TIMEOUT_MS);
// Register with central registry
this.registry.registerIncomingRequest(
requestId,
this.controllerName,
requestAbortController,
timeoutId,
);
try {
const response = await this.handleRequestPayload(
payload,
requestAbortController.signal,
);
// Success - deregister
this.registry.deregisterIncomingRequest(requestId);
return response;
} catch (error) {
// Error - deregister
this.registry.deregisterIncomingRequest(requestId);
throw error;
}
}
/**
* Send an outgoing control request to SDK
*
* Manages lifecycle: register -> send -> wait for response -> deregister
*/
async sendControlRequest(
payload: ControlRequestPayload,
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
): Promise<ControlResponse> {
const requestId = randomUUID();
return new Promise<ControlResponse>((resolve, reject) => {
// Setup timeout
const timeoutId = setTimeout(() => {
this.registry.deregisterOutgoingRequest(requestId);
reject(new Error('Control request timeout'));
if (this.context.debugMode) {
console.error(
`[${this.controllerName}] Outgoing request timeout: ${requestId}`,
);
}
}, timeoutMs);
// Register with central registry
this.registry.registerOutgoingRequest(
requestId,
this.controllerName,
resolve,
reject,
timeoutId,
);
// Send control request
const request: CLIControlRequest = {
type: 'control_request',
request_id: requestId,
request: payload,
};
try {
this.context.streamJson.send(request);
} catch (error) {
this.registry.deregisterOutgoingRequest(requestId);
reject(error);
}
});
}
/**
* Abstract method: Handle specific request payload
*
* Subclasses must implement this to process their domain-specific requests.
*/
protected abstract handleRequestPayload(
payload: ControlRequestPayload,
signal: AbortSignal,
): Promise<Record<string, unknown>>;
/**
* Cleanup resources
*/
cleanup(): void {
// Subclasses can override to add cleanup logic
}
}

View File

@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Hook Controller
*
* Handles hook-related control requests:
* - hook_callback: Process hook callbacks (placeholder for future)
*/
import { BaseController } from './baseController.js';
import type {
ControlRequestPayload,
CLIHookCallbackRequest,
} from '../../types.js';
export class HookController extends BaseController {
/**
* Handle hook control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'hook_callback':
return this.handleHookCallback(payload as CLIHookCallbackRequest);
default:
throw new Error(`Unsupported request subtype in HookController`);
}
}
/**
* Handle hook_callback request
*
* Processes hook callbacks (placeholder implementation)
*/
private async handleHookCallback(
payload: CLIHookCallbackRequest,
): Promise<Record<string, unknown>> {
if (this.context.debugMode) {
console.error(`[HookController] Hook callback: ${payload.callback_id}`);
}
// Hook callback processing not yet implemented
return {
result: 'Hook callback processing not yet implemented',
callback_id: payload.callback_id,
tool_use_id: payload.tool_use_id,
};
}
}

View File

@@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* MCP Controller
*
* Handles MCP-related control requests:
* - mcp_message: Route MCP messages
* - mcp_server_status: Return MCP server status
*/
import { BaseController } from './baseController.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ResultSchema } from '@modelcontextprotocol/sdk/types.js';
import type {
ControlRequestPayload,
CLIControlMcpMessageRequest,
} from '../../types.js';
import type {
MCPServerConfig,
WorkspaceContext,
} from '@qwen-code/qwen-code-core';
import {
connectToMcpServer,
MCP_DEFAULT_TIMEOUT_MSEC,
} from '@qwen-code/qwen-code-core';
export class MCPController extends BaseController {
/**
* Handle MCP control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'mcp_message':
return this.handleMcpMessage(payload as CLIControlMcpMessageRequest);
case 'mcp_server_status':
return this.handleMcpStatus();
default:
throw new Error(`Unsupported request subtype in MCPController`);
}
}
/**
* Handle mcp_message request
*
* Routes JSON-RPC messages to MCP servers
*/
private async handleMcpMessage(
payload: CLIControlMcpMessageRequest,
): Promise<Record<string, unknown>> {
const serverNameRaw = payload.server_name;
if (
typeof serverNameRaw !== 'string' ||
serverNameRaw.trim().length === 0
) {
throw new Error('Missing server_name in mcp_message request');
}
const message = payload.message;
if (!message || typeof message !== 'object') {
throw new Error(
'Missing or invalid message payload for mcp_message request',
);
}
// Get or create MCP client
let clientEntry: { client: Client; config: MCPServerConfig };
try {
clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim());
} catch (error) {
throw new Error(
error instanceof Error
? error.message
: 'Failed to connect to MCP server',
);
}
const method = message.method;
if (typeof method !== 'string' || method.trim().length === 0) {
throw new Error('Invalid MCP message: missing method');
}
const jsonrpcVersion =
typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0';
const messageId = message.id;
const params = message.params;
const timeout =
typeof clientEntry.config.timeout === 'number'
? clientEntry.config.timeout
: MCP_DEFAULT_TIMEOUT_MSEC;
try {
// Handle notification (no id)
if (messageId === undefined) {
await clientEntry.client.notification({
method,
params,
});
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: null,
result: { success: true, acknowledged: true },
},
};
}
// Handle request (with id)
const result = await clientEntry.client.request(
{
method,
params,
},
ResultSchema,
{ timeout },
);
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: messageId,
result,
},
};
} catch (error) {
// If connection closed, remove from cache
if (error instanceof Error && /closed/i.test(error.message)) {
this.context.mcpClients.delete(serverNameRaw.trim());
}
const errorCode =
typeof (error as { code?: unknown })?.code === 'number'
? ((error as { code: number }).code as number)
: -32603;
const errorMessage =
error instanceof Error
? error.message
: 'Failed to execute MCP request';
const errorData = (error as { data?: unknown })?.data;
const errorBody: Record<string, unknown> = {
code: errorCode,
message: errorMessage,
};
if (errorData !== undefined) {
errorBody['data'] = errorData;
}
return {
subtype: 'mcp_message',
mcp_response: {
jsonrpc: jsonrpcVersion,
id: messageId ?? null,
error: errorBody,
},
};
}
}
/**
* Handle mcp_server_status request
*
* Returns status of registered MCP servers
*/
private async handleMcpStatus(): Promise<Record<string, unknown>> {
const status: Record<string, string> = {};
// Include SDK MCP servers
for (const serverName of this.context.sdkMcpServers) {
status[serverName] = 'connected';
}
// Include CLI-managed MCP clients
for (const serverName of this.context.mcpClients.keys()) {
status[serverName] = 'connected';
}
if (this.context.debugMode) {
console.error(
`[MCPController] MCP status: ${Object.keys(status).length} servers`,
);
}
return status;
}
/**
* Get or create MCP client for a server
*
* Implements lazy connection and caching
*/
private async getOrCreateMcpClient(
serverName: string,
): Promise<{ client: Client; config: MCPServerConfig }> {
// Check cache first
const cached = this.context.mcpClients.get(serverName);
if (cached) {
return cached;
}
// Get server configuration
const provider = this.context.config as unknown as {
getMcpServers?: () => Record<string, MCPServerConfig> | undefined;
getDebugMode?: () => boolean;
getWorkspaceContext?: () => unknown;
};
if (typeof provider.getMcpServers !== 'function') {
throw new Error(`MCP server "${serverName}" is not configured`);
}
const servers = provider.getMcpServers() ?? {};
const serverConfig = servers[serverName];
if (!serverConfig) {
throw new Error(`MCP server "${serverName}" is not configured`);
}
const debugMode =
typeof provider.getDebugMode === 'function'
? provider.getDebugMode()
: false;
const workspaceContext =
typeof provider.getWorkspaceContext === 'function'
? provider.getWorkspaceContext()
: undefined;
if (!workspaceContext) {
throw new Error('Workspace context is not available for MCP connection');
}
// Connect to MCP server
const client = await connectToMcpServer(
serverName,
serverConfig,
debugMode,
workspaceContext as WorkspaceContext,
);
// Cache the client
const entry = { client, config: serverConfig };
this.context.mcpClients.set(serverName, entry);
if (this.context.debugMode) {
console.error(`[MCPController] Connected to MCP server: ${serverName}`);
}
return entry;
}
/**
* Cleanup MCP clients
*/
override cleanup(): void {
if (this.context.debugMode) {
console.error(
`[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`,
);
}
// Close all MCP clients
for (const [serverName, { client }] of this.context.mcpClients.entries()) {
try {
client.close();
} catch (error) {
if (this.context.debugMode) {
console.error(
`[MCPController] Failed to close MCP client ${serverName}:`,
error,
);
}
}
}
this.context.mcpClients.clear();
}
}

View File

@@ -0,0 +1,483 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Permission Controller
*
* Handles permission-related control requests:
* - can_use_tool: Check if tool usage is allowed
* - set_permission_mode: Change permission mode at runtime
*
* Abstracts all permission logic from the session manager to keep it clean.
*/
import type {
ToolCallRequestInfo,
WaitingToolCall,
} from '@qwen-code/qwen-code-core';
import {
InputFormat,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
import type {
CLIControlPermissionRequest,
CLIControlSetPermissionModeRequest,
ControlRequestPayload,
PermissionMode,
PermissionSuggestion,
} from '../../types.js';
import { BaseController } from './baseController.js';
// Import ToolCallConfirmationDetails types for type alignment
type ToolConfirmationType = 'edit' | 'exec' | 'mcp' | 'info' | 'plan';
export class PermissionController extends BaseController {
private pendingOutgoingRequests = new Set<string>();
/**
* Handle permission control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'can_use_tool':
return this.handleCanUseTool(payload as CLIControlPermissionRequest);
case 'set_permission_mode':
return this.handleSetPermissionMode(
payload as CLIControlSetPermissionModeRequest,
);
default:
throw new Error(`Unsupported request subtype in PermissionController`);
}
}
/**
* Handle can_use_tool request
*
* Comprehensive permission evaluation based on:
* - Permission mode (approval level)
* - Tool registry validation
* - Error handling with safe defaults
*/
private async handleCanUseTool(
payload: CLIControlPermissionRequest,
): Promise<Record<string, unknown>> {
const toolName = payload.tool_name;
if (
!toolName ||
typeof toolName !== 'string' ||
toolName.trim().length === 0
) {
return {
subtype: 'can_use_tool',
behavior: 'deny',
message: 'Missing or invalid tool_name in can_use_tool request',
};
}
let behavior: 'allow' | 'deny' = 'allow';
let message: string | undefined;
try {
// Check permission mode first
const permissionResult = this.checkPermissionMode();
if (!permissionResult.allowed) {
behavior = 'deny';
message = permissionResult.message;
}
// Check tool registry if permission mode allows
if (behavior === 'allow') {
const registryResult = this.checkToolRegistry(toolName);
if (!registryResult.allowed) {
behavior = 'deny';
message = registryResult.message;
}
}
} catch (error) {
behavior = 'deny';
message =
error instanceof Error
? `Failed to evaluate tool permission: ${error.message}`
: 'Failed to evaluate tool permission';
}
const response: Record<string, unknown> = {
subtype: 'can_use_tool',
behavior,
};
if (message) {
response['message'] = message;
}
return response;
}
/**
* Check permission mode for tool execution
*/
private checkPermissionMode(): { allowed: boolean; message?: string } {
const mode = this.context.permissionMode;
// Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES)
switch (mode) {
case 'yolo': // Allow all tools
case 'auto-edit': // Auto-approve edit operations
case 'plan': // Auto-approve planning operations
return { allowed: true };
case 'default': // TODO: allow all tools for test
default:
return {
allowed: false,
message:
'Tool execution requires manual approval. Update permission mode or approve via host.',
};
}
}
/**
* Check if tool exists in registry
*/
private checkToolRegistry(toolName: string): {
allowed: boolean;
message?: string;
} {
try {
// Access tool registry through config
const config = this.context.config;
const registryProvider = config as unknown as {
getToolRegistry?: () => {
getTool?: (name: string) => unknown;
};
};
if (typeof registryProvider.getToolRegistry === 'function') {
const registry = registryProvider.getToolRegistry();
if (
registry &&
typeof registry.getTool === 'function' &&
!registry.getTool(toolName)
) {
return {
allowed: false,
message: `Tool "${toolName}" is not registered.`,
};
}
}
return { allowed: true };
} catch (error) {
return {
allowed: false,
message: `Failed to check tool registry: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
}
/**
* Handle set_permission_mode request
*
* Updates the permission mode in the context
*/
private async handleSetPermissionMode(
payload: CLIControlSetPermissionModeRequest,
): Promise<Record<string, unknown>> {
const mode = payload.mode;
const validModes: PermissionMode[] = [
'default',
'plan',
'auto-edit',
'yolo',
];
if (!validModes.includes(mode)) {
throw new Error(
`Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`,
);
}
this.context.permissionMode = mode;
if (this.context.debugMode) {
console.error(
`[PermissionController] Permission mode updated to: ${mode}`,
);
}
return { status: 'updated', mode };
}
/**
* Build permission suggestions for tool confirmation UI
*
* This method creates UI suggestions based on tool confirmation details,
* helping the host application present appropriate permission options.
*/
buildPermissionSuggestions(
confirmationDetails: unknown,
): PermissionSuggestion[] | null {
if (
!confirmationDetails ||
typeof confirmationDetails !== 'object' ||
!('type' in confirmationDetails)
) {
return null;
}
const details = confirmationDetails as Record<string, unknown>;
const type = String(details['type'] ?? '');
const title =
typeof details['title'] === 'string' ? details['title'] : undefined;
// Ensure type matches ToolCallConfirmationDetails union
const confirmationType = type as ToolConfirmationType;
switch (confirmationType) {
case 'exec': // ToolExecuteConfirmationDetails
return [
{
type: 'allow',
label: 'Allow Command',
description: `Execute: ${details['command']}`,
},
{
type: 'deny',
label: 'Deny',
description: 'Block this command execution',
},
];
case 'edit': // ToolEditConfirmationDetails
return [
{
type: 'allow',
label: 'Allow Edit',
description: `Edit file: ${details['fileName']}`,
},
{
type: 'deny',
label: 'Deny',
description: 'Block this file edit',
},
{
type: 'modify',
label: 'Review Changes',
description: 'Review the proposed changes before applying',
},
];
case 'plan': // ToolPlanConfirmationDetails
return [
{
type: 'allow',
label: 'Approve Plan',
description: title || 'Execute the proposed plan',
},
{
type: 'deny',
label: 'Reject Plan',
description: 'Do not execute this plan',
},
];
case 'mcp': // ToolMcpConfirmationDetails
return [
{
type: 'allow',
label: 'Allow MCP Call',
description: `${details['serverName']}: ${details['toolName']}`,
},
{
type: 'deny',
label: 'Deny',
description: 'Block this MCP server call',
},
];
case 'info': // ToolInfoConfirmationDetails
return [
{
type: 'allow',
label: 'Allow Info Request',
description: title || 'Allow information request',
},
{
type: 'deny',
label: 'Deny',
description: 'Block this information request',
},
];
default:
// Fallback for unknown types
return [
{
type: 'allow',
label: 'Allow',
description: title || `Allow ${type} operation`,
},
{
type: 'deny',
label: 'Deny',
description: `Block ${type} operation`,
},
];
}
}
/**
* Check if a tool should be executed based on current permission settings
*
* This is a convenience method for direct tool execution checks without
* going through the control request flow.
*/
async shouldAllowTool(
toolRequest: ToolCallRequestInfo,
confirmationDetails?: unknown,
): Promise<{
allowed: boolean;
message?: string;
updatedArgs?: Record<string, unknown>;
}> {
// Check permission mode
const modeResult = this.checkPermissionMode();
if (!modeResult.allowed) {
return {
allowed: false,
message: modeResult.message,
};
}
// Check tool registry
const registryResult = this.checkToolRegistry(toolRequest.name);
if (!registryResult.allowed) {
return {
allowed: false,
message: registryResult.message,
};
}
// If we have confirmation details, we could potentially modify args
// This is a hook for future enhancement
if (confirmationDetails) {
// Future: handle argument modifications based on confirmation details
}
return { allowed: true };
}
/**
* Get callback for monitoring tool calls and handling outgoing permission requests
* This is passed to executeToolCall to hook into CoreToolScheduler updates
*/
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void {
return (toolCalls: unknown[]) => {
for (const call of toolCalls) {
if (
call &&
typeof call === 'object' &&
(call as { status?: string }).status === 'awaiting_approval'
) {
const awaiting = call as WaitingToolCall;
if (
typeof awaiting.confirmationDetails?.onConfirm === 'function' &&
!this.pendingOutgoingRequests.has(awaiting.request.callId)
) {
this.pendingOutgoingRequests.add(awaiting.request.callId);
void this.handleOutgoingPermissionRequest(awaiting);
}
}
}
};
}
/**
* Handle outgoing permission request
*
* Behavior depends on input format:
* - stream-json mode: Send can_use_tool to SDK and await response
* - Other modes: Check local approval mode and decide immediately
*/
private async handleOutgoingPermissionRequest(
toolCall: WaitingToolCall,
): Promise<void> {
try {
const inputFormat = this.context.config.getInputFormat?.();
const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON;
if (!isStreamJsonMode) {
// No SDK available - use local permission check
const modeCheck = this.checkPermissionMode();
const outcome = modeCheck.allowed
? ToolConfirmationOutcome.ProceedOnce
: ToolConfirmationOutcome.Cancel;
await toolCall.confirmationDetails.onConfirm(outcome);
return;
}
// Stream-json mode: ask SDK for permission
const permissionSuggestions = this.buildPermissionSuggestions(
toolCall.confirmationDetails,
);
const response = await this.sendControlRequest(
{
subtype: 'can_use_tool',
tool_name: toolCall.request.name,
tool_use_id: toolCall.request.callId,
input: toolCall.request.args,
permission_suggestions: permissionSuggestions,
blocked_path: null,
} as CLIControlPermissionRequest,
30000,
);
if (response.subtype !== 'success') {
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
return;
}
const payload = (response.response || {}) as Record<string, unknown>;
const behavior = String(payload['behavior'] || '').toLowerCase();
if (behavior === 'allow') {
// Handle updated input if provided
const updatedInput = payload['updatedInput'];
if (updatedInput && typeof updatedInput === 'object') {
toolCall.request.args = updatedInput as Record<string, unknown>;
}
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
);
} else {
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
}
} catch (error) {
if (this.context.debugMode) {
console.error(
'[PermissionController] Outgoing permission failed:',
error,
);
}
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
);
} finally {
this.pendingOutgoingRequests.delete(toolCall.request.callId);
}
}
}

View File

@@ -0,0 +1,215 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* System Controller
*
* Handles system-level control requests:
* - initialize: Setup session and return system info
* - interrupt: Cancel current operations
* - set_model: Switch model (placeholder)
*/
import { BaseController } from './baseController.js';
import type {
ControlRequestPayload,
CLIControlInitializeRequest,
CLIControlSetModelRequest,
} from '../../types.js';
export class SystemController extends BaseController {
/**
* Handle system control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
switch (payload.subtype) {
case 'initialize':
return this.handleInitialize(payload as CLIControlInitializeRequest);
case 'interrupt':
return this.handleInterrupt();
case 'set_model':
return this.handleSetModel(payload as CLIControlSetModelRequest);
case 'supported_commands':
return this.handleSupportedCommands();
default:
throw new Error(`Unsupported request subtype in SystemController`);
}
}
/**
* Handle initialize request
*
* Registers SDK MCP servers and returns capabilities
*/
private async handleInitialize(
payload: CLIControlInitializeRequest,
): Promise<Record<string, unknown>> {
// Register SDK MCP servers if provided
if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) {
for (const serverName of payload.sdkMcpServers) {
this.context.sdkMcpServers.add(serverName);
}
}
// Build capabilities for response
const capabilities = this.buildControlCapabilities();
if (this.context.debugMode) {
console.error(
`[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`,
);
}
return {
subtype: 'initialize',
capabilities,
};
}
/**
* Build control capabilities for initialize control response
*
* This method constructs the control capabilities object that indicates
* what control features are available. It is used exclusively in the
* initialize control response.
*/
buildControlCapabilities(): Record<string, unknown> {
const capabilities: Record<string, unknown> = {
can_handle_can_use_tool: true,
can_handle_hook_callback: true,
can_set_permission_mode:
typeof this.context.config.setApprovalMode === 'function',
can_set_model: typeof this.context.config.setModel === 'function',
};
// Check if MCP message handling is available
try {
const mcpProvider = this.context.config as unknown as {
getMcpServers?: () => Record<string, unknown> | undefined;
};
if (typeof mcpProvider.getMcpServers === 'function') {
const servers = mcpProvider.getMcpServers();
capabilities['can_handle_mcp_message'] = Boolean(
servers && Object.keys(servers).length > 0,
);
} else {
capabilities['can_handle_mcp_message'] = false;
}
} catch (error) {
if (this.context.debugMode) {
console.error(
'[SystemController] Failed to determine MCP capability:',
error,
);
}
capabilities['can_handle_mcp_message'] = false;
}
return capabilities;
}
/**
* Handle interrupt request
*
* Triggers the interrupt callback to cancel current operations
*/
private async handleInterrupt(): Promise<Record<string, unknown>> {
// Trigger interrupt callback if available
if (this.context.onInterrupt) {
this.context.onInterrupt();
}
// Abort the main signal to cancel ongoing operations
if (this.context.abortSignal && !this.context.abortSignal.aborted) {
// Note: We can't directly abort the signal, but the onInterrupt callback should handle this
if (this.context.debugMode) {
console.error('[SystemController] Interrupt signal triggered');
}
}
if (this.context.debugMode) {
console.error('[SystemController] Interrupt handled');
}
return { subtype: 'interrupt' };
}
/**
* Handle set_model request
*
* Implements actual model switching with validation and error handling
*/
private async handleSetModel(
payload: CLIControlSetModelRequest,
): Promise<Record<string, unknown>> {
const model = payload.model;
// Validate model parameter
if (typeof model !== 'string' || model.trim() === '') {
throw new Error('Invalid model specified for set_model request');
}
try {
// Attempt to set the model using config
await this.context.config.setModel(model);
if (this.context.debugMode) {
console.error(`[SystemController] Model switched to: ${model}`);
}
return {
subtype: 'set_model',
model,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to set model';
if (this.context.debugMode) {
console.error(
`[SystemController] Failed to set model ${model}:`,
error,
);
}
throw new Error(errorMessage);
}
}
/**
* Handle supported_commands request
*
* Returns list of supported control commands
*
* Note: This list should match the ControlRequestType enum in
* packages/sdk/typescript/src/types/controlRequests.ts
*/
private async handleSupportedCommands(): Promise<Record<string, unknown>> {
const commands = [
'initialize',
'interrupt',
'set_model',
'supported_commands',
'can_use_tool',
'set_permission_mode',
'mcp_message',
'mcp_server_status',
'hook_callback',
];
return {
subtype: 'supported_commands',
commands,
};
}
}

View File

@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Service API Types
*
* These interfaces define the public API contract for the ControlService facade.
* They provide type-safe, domain-grouped access to control plane functionality
* for internal CLI code (nonInteractiveCli, session managers, etc.).
*/
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
import type {
ToolCallRequestInfo,
MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import type { PermissionSuggestion } from '../../types.js';
/**
* Permission Service API
*
* Provides permission-related operations including tool execution approval,
* permission suggestions, and tool call monitoring callbacks.
*/
export interface PermissionServiceAPI {
/**
* Check if a tool should be allowed based on current permission settings
*
* Evaluates permission mode and tool registry to determine if execution
* should proceed. Can optionally modify tool arguments based on confirmation details.
*
* @param toolRequest - Tool call request information containing name, args, and call ID
* @param confirmationDetails - Optional confirmation details for UI-driven approvals
* @returns Promise resolving to permission decision with optional updated arguments
*/
shouldAllowTool(
toolRequest: ToolCallRequestInfo,
confirmationDetails?: unknown,
): Promise<{
allowed: boolean;
message?: string;
updatedArgs?: Record<string, unknown>;
}>;
/**
* Build UI suggestions for tool confirmation dialogs
*
* Creates actionable permission suggestions based on tool confirmation details,
* helping host applications present appropriate approval/denial options.
*
* @param confirmationDetails - Tool confirmation details (type, title, metadata)
* @returns Array of permission suggestions or null if details are invalid
*/
buildPermissionSuggestions(
confirmationDetails: unknown,
): PermissionSuggestion[] | null;
/**
* Get callback for monitoring tool call status updates
*
* Returns a callback function that should be passed to executeToolCall
* to enable integration with CoreToolScheduler updates. This callback
* handles outgoing permission requests for tools awaiting approval.
*
* @returns Callback function that processes tool call updates
*/
getToolCallUpdateCallback(): (toolCalls: unknown[]) => void;
}
/**
* System Service API
*
* Provides system-level operations for the control system.
*
* Note: System messages and slash commands are NOT part of the control system API.
* They are handled independently via buildSystemMessage() from nonInteractiveHelpers.ts,
* regardless of whether the control system is available.
*/
export interface SystemServiceAPI {
/**
* Get control capabilities
*
* Returns the control capabilities object indicating what control
* features are available. Used exclusively for the initialize control
* response. System messages do not include capabilities as they are
* independent of the control system.
*
* @returns Control capabilities object
*/
getControlCapabilities(): Record<string, unknown>;
}
/**
* MCP Service API
*
* Provides Model Context Protocol server interaction including
* lazy client initialization and server discovery.
*/
export interface McpServiceAPI {
/**
* Get or create MCP client for a server (lazy initialization)
*
* Returns an existing client from cache or creates a new connection
* if this is the first request for the server. Handles connection
* lifecycle and error recovery.
*
* @param serverName - Name of the MCP server to connect to
* @returns Promise resolving to client instance and server configuration
* @throws Error if server is not configured or connection fails
*/
getMcpClient(serverName: string): Promise<{
client: Client;
config: MCPServerConfig;
}>;
/**
* List all available MCP servers
*
* Returns names of both SDK-managed and CLI-managed MCP servers
* that are currently configured or connected.
*
* @returns Array of server names
*/
listServers(): string[];
}
/**
* Hook Service API
*
* Provides hook callback processing (placeholder for future expansion).
*/
export interface HookServiceAPI {
// Future: Hook-related methods will be added here
// For now, hook functionality is handled only via control requests
registerHookCallback(callback: unknown): void;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,791 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type {
Config,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { GeminiEventType } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import { JsonOutputAdapter } from './JsonOutputAdapter.js';
function createMockConfig(): Config {
return {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getModel: vi.fn().mockReturnValue('test-model'),
} as unknown as Config;
}
describe('JsonOutputAdapter', () => {
let adapter: JsonOutputAdapter;
let mockConfig: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stdoutWriteSpy: any;
beforeEach(() => {
mockConfig = createMockConfig();
adapter = new JsonOutputAdapter(mockConfig);
stdoutWriteSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutWriteSpy.mockRestore();
});
describe('startAssistantMessage', () => {
it('should reset state for new message', () => {
adapter.startAssistantMessage();
adapter.startAssistantMessage(); // Start second message
// Should not throw
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
});
});
describe('processEvent', () => {
beforeEach(() => {
adapter.startAssistantMessage();
});
it('should append text content from Content events', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Content,
value: 'Hello',
};
adapter.processEvent(event);
const event2: ServerGeminiStreamEvent = {
type: GeminiEventType.Content,
value: ' World',
};
adapter.processEvent(event2);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'Hello World',
});
});
it('should append citation content from Citation events', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Citation,
value: 'Citation text',
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('Citation text'),
});
});
it('should ignore non-string citation values', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Citation,
value: 123,
} as unknown as ServerGeminiStreamEvent;
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(0);
});
it('should append thinking from Thought events', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking about the task',
},
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'thinking',
thinking: 'Planning: Thinking about the task',
signature: 'Planning',
});
});
it('should handle thinking with only subject', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: '',
},
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'thinking',
signature: 'Planning',
});
});
it('should append tool use from ToolCallRequest events', () => {
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'tool_use',
id: 'tool-call-1',
name: 'test_tool',
input: { param1: 'value1' },
});
});
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBe('tool_use');
});
it('should set stop_reason to null when message contains text blocks', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Some text',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBeNull();
});
it('should set stop_reason to null when message contains thinking blocks', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking about the task',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBeNull();
});
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool_1',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-2',
name: 'test_tool_2',
args: { param2: 'value2' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(2);
expect(
message.message.content.every((block) => block.type === 'tool_use'),
).toBe(true);
expect(message.message.stop_reason).toBe('tool_use');
});
it('should update usage from Finished event', () => {
const usageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 50,
cachedContentTokenCount: 10,
totalTokenCount: 160,
};
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata,
},
};
adapter.processEvent(event);
const message = adapter.finalizeAssistantMessage();
expect(message.message.usage).toMatchObject({
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 10,
total_tokens: 160,
});
});
it('should finalize pending blocks on Finished event', () => {
// Add some text first
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Some text',
});
const event: ServerGeminiStreamEvent = {
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: undefined },
};
adapter.processEvent(event);
// Should not throw when finalizing
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
});
it('should ignore events after finalization', () => {
adapter.finalizeAssistantMessage();
const originalContent =
adapter.finalizeAssistantMessage().message.content;
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Should be ignored',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toEqual(originalContent);
});
});
describe('finalizeAssistantMessage', () => {
beforeEach(() => {
adapter.startAssistantMessage();
});
it('should build and emit a complete assistant message', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test response',
});
const message = adapter.finalizeAssistantMessage();
expect(message.type).toBe('assistant');
expect(message.uuid).toBeTruthy();
expect(message.session_id).toBe('test-session-id');
expect(message.parent_tool_use_id).toBeNull();
expect(message.message.role).toBe('assistant');
expect(message.message.model).toBe('test-model');
expect(message.message.content).toHaveLength(1);
});
it('should return same message on subsequent calls', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test',
});
const message1 = adapter.finalizeAssistantMessage();
const message2 = adapter.finalizeAssistantMessage();
expect(message1).toEqual(message2);
});
it('should split different block types into separate assistant messages', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: { subject: 'Thinking', description: 'Thought' },
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0].type).toBe('thinking');
const storedMessages = (adapter as unknown as { messages: unknown[] })
.messages;
const assistantMessages = storedMessages.filter(
(
msg,
): msg is {
type: string;
message: { content: Array<{ type: string }> };
} => {
if (
typeof msg !== 'object' ||
msg === null ||
!('type' in msg) ||
(msg as { type?: string }).type !== 'assistant' ||
!('message' in msg)
) {
return false;
}
const message = (msg as { message?: unknown }).message;
return (
typeof message === 'object' &&
message !== null &&
'content' in message &&
Array.isArray((message as { content?: unknown }).content)
);
},
);
expect(assistantMessages).toHaveLength(2);
for (const assistant of assistantMessages) {
const uniqueTypes = new Set(
assistant.message.content.map((block) => block.type),
);
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
}
});
it('should throw if message not started', () => {
adapter = new JsonOutputAdapter(mockConfig);
expect(() => adapter.finalizeAssistantMessage()).toThrow(
'Message not started',
);
});
});
describe('emitResult', () => {
beforeEach(() => {
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Response text',
});
adapter.finalizeAssistantMessage();
});
it('should emit success result as JSON array', () => {
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage).toBeDefined();
expect(resultMessage.is_error).toBe(false);
expect(resultMessage.subtype).toBe('success');
expect(resultMessage.result).toBe('Response text');
expect(resultMessage.duration_ms).toBe(1000);
expect(resultMessage.num_turns).toBe(1);
});
it('should emit error result', () => {
adapter.emitResult({
isError: true,
errorMessage: 'Test error',
durationMs: 500,
apiDurationMs: 300,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage.is_error).toBe(true);
expect(resultMessage.subtype).toBe('error_during_execution');
expect(resultMessage.error?.message).toBe('Test error');
});
it('should use provided summary over extracted text', () => {
adapter.emitResult({
isError: false,
summary: 'Custom summary',
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage.result).toBe('Custom summary');
});
it('should include usage information', () => {
const usage = {
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
};
adapter.emitResult({
isError: false,
usage,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage.usage).toEqual(usage);
});
it('should include stats when provided', () => {
const stats = {
models: {},
tools: {
totalCalls: 5,
totalSuccess: 4,
totalFail: 1,
totalDurationMs: 1000,
totalDecisions: {
accept: 3,
reject: 1,
modify: 0,
auto_accept: 1,
},
byName: {},
},
files: {
totalLinesAdded: 10,
totalLinesRemoved: 5,
},
};
adapter.emitResult({
isError: false,
stats,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const resultMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'result',
);
expect(resultMessage.stats).toEqual(stats);
});
});
describe('emitUserMessage', () => {
it('should add user message to collection', () => {
const parts: Part[] = [{ text: 'Hello user' }];
adapter.emitUserMessage(parts);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const userMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'user',
);
expect(userMessage).toBeDefined();
expect(Array.isArray(userMessage.message.content)).toBe(true);
if (Array.isArray(userMessage.message.content)) {
expect(userMessage.message.content).toHaveLength(1);
expect(userMessage.message.content[0]).toEqual({
type: 'text',
text: 'Hello user',
});
}
});
it('should handle parent_tool_use_id', () => {
const parts: Part[] = [{ text: 'Tool response' }];
adapter.emitUserMessage(parts);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const userMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'user',
);
// emitUserMessage currently sets parent_tool_use_id to null
expect(userMessage.parent_tool_use_id).toBeNull();
});
});
describe('emitToolResult', () => {
it('should emit tool result message', () => {
const request = {
callId: 'tool-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const response = {
callId: 'tool-1',
responseParts: [],
resultDisplay: 'Tool executed successfully',
error: undefined,
errorType: undefined,
};
adapter.emitToolResult(request, response);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const toolResult = parsed.find(
(
msg: unknown,
): msg is { type: 'user'; message: { content: unknown[] } } =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'user' &&
'message' in msg &&
typeof msg.message === 'object' &&
msg.message !== null &&
'content' in msg.message &&
Array.isArray(msg.message.content) &&
msg.message.content[0] &&
typeof msg.message.content[0] === 'object' &&
'type' in msg.message.content[0] &&
msg.message.content[0].type === 'tool_result',
);
expect(toolResult).toBeDefined();
const block = toolResult.message.content[0] as {
type: 'tool_result';
tool_use_id: string;
content?: string;
is_error?: boolean;
};
expect(block).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'Tool executed successfully',
is_error: false,
});
});
it('should mark error tool results', () => {
const request = {
callId: 'tool-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const response = {
callId: 'tool-1',
responseParts: [],
resultDisplay: undefined,
error: new Error('Tool failed'),
errorType: undefined,
};
adapter.emitToolResult(request, response);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const toolResult = parsed.find(
(
msg: unknown,
): msg is { type: 'user'; message: { content: unknown[] } } =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'user' &&
'message' in msg &&
typeof msg.message === 'object' &&
msg.message !== null &&
'content' in msg.message &&
Array.isArray(msg.message.content),
);
const block = toolResult.message.content[0] as {
is_error?: boolean;
};
expect(block.is_error).toBe(true);
});
});
describe('emitSystemMessage', () => {
it('should add system message to collection', () => {
adapter.emitSystemMessage('test_subtype', { data: 'value' });
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const systemMessage = parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
msg.type === 'system',
);
expect(systemMessage).toBeDefined();
expect(systemMessage.subtype).toBe('test_subtype');
expect(systemMessage.data).toEqual({ data: 'value' });
});
});
describe('getSessionId and getModel', () => {
it('should return session ID from config', () => {
expect(adapter.getSessionId()).toBe('test-session-id');
expect(mockConfig.getSessionId).toHaveBeenCalled();
});
it('should return model from config', () => {
expect(adapter.getModel()).toBe('test-model');
expect(mockConfig.getModel).toHaveBeenCalled();
});
});
describe('multiple messages in collection', () => {
it('should collect all messages and emit as array', () => {
adapter.emitSystemMessage('init', {});
adapter.emitUserMessage([{ text: 'User input' }]);
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Assistant response',
});
adapter.finalizeAssistantMessage();
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBeGreaterThanOrEqual(3);
const systemMsg = parsed[0] as { type?: string };
const userMsg = parsed[1] as { type?: string };
expect(systemMsg.type).toBe('system');
expect(userMsg.type).toBe('user');
expect(
parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
(msg as { type?: string }).type === 'assistant',
),
).toBeDefined();
expect(
parsed.find(
(msg: unknown) =>
typeof msg === 'object' &&
msg !== null &&
'type' in msg &&
(msg as { type?: string }).type === 'result',
),
).toBeDefined();
});
});
});

View File

@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@qwen-code/qwen-code-core';
import type { CLIAssistantMessage, CLIMessage } from '../types.js';
import {
BaseJsonOutputAdapter,
type JsonOutputAdapterInterface,
type ResultOptions,
} from './BaseJsonOutputAdapter.js';
/**
* JSON output adapter that collects all messages and emits them
* as a single JSON array at the end of the turn.
* Supports both main agent and subagent messages through distinct APIs.
*/
export class JsonOutputAdapter
extends BaseJsonOutputAdapter
implements JsonOutputAdapterInterface
{
private readonly messages: CLIMessage[] = [];
constructor(config: Config) {
super(config);
}
/**
* Emits message to the messages array (batch mode).
* Tracks the last assistant message for efficient result text extraction.
*/
protected emitMessageImpl(message: CLIMessage): void {
this.messages.push(message);
// Track assistant messages for result generation
if (
typeof message === 'object' &&
message !== null &&
'type' in message &&
message.type === 'assistant'
) {
this.updateLastAssistantMessage(message as CLIAssistantMessage);
}
}
/**
* JSON mode does not emit stream events.
*/
protected shouldEmitStreamEvents(): boolean {
return false;
}
finalizeAssistantMessage(): CLIAssistantMessage {
const message = this.finalizeAssistantMessageInternal(
this.mainAgentMessageState,
null,
);
this.updateLastAssistantMessage(message);
return message;
}
emitResult(options: ResultOptions): void {
const resultMessage = this.buildResultMessage(
options,
this.lastAssistantMessage,
);
this.messages.push(resultMessage);
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
const json = JSON.stringify(this.messages);
process.stdout.write(`${json}\n`);
}
emitMessage(message: CLIMessage): void {
// In JSON mode, messages are collected in the messages array
// This is called by the base class's finalizeAssistantMessageInternal
// but can also be called directly for user/tool/system messages
this.messages.push(message);
}
}

View File

@@ -0,0 +1,215 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { PassThrough } from 'node:stream';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
StreamJsonInputReader,
StreamJsonParseError,
type StreamJsonInputMessage,
} from './StreamJsonInputReader.js';
describe('StreamJsonInputReader', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('read', () => {
/**
* Test parsing all supported message types in a single test
*/
it('should parse valid messages of all types', async () => {
const input = new PassThrough();
const reader = new StreamJsonInputReader(input);
const messages = [
{
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello world' }],
},
parent_tool_use_id: null,
},
{
type: 'control_request',
request_id: 'req-1',
request: { subtype: 'initialize' },
},
{
type: 'control_response',
response: {
subtype: 'success',
request_id: 'req-1',
response: { initialized: true },
},
},
{
type: 'control_cancel_request',
request_id: 'req-1',
},
];
for (const msg of messages) {
input.write(JSON.stringify(msg) + '\n');
}
input.end();
const parsed: StreamJsonInputMessage[] = [];
for await (const msg of reader.read()) {
parsed.push(msg);
}
expect(parsed).toHaveLength(messages.length);
expect(parsed).toEqual(messages);
});
it('should parse multiple messages', async () => {
const input = new PassThrough();
const reader = new StreamJsonInputReader(input);
const message1 = {
type: 'control_request',
request_id: 'req-1',
request: { subtype: 'initialize' },
};
const message2 = {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello' }],
},
parent_tool_use_id: null,
};
input.write(JSON.stringify(message1) + '\n');
input.write(JSON.stringify(message2) + '\n');
input.end();
const messages: StreamJsonInputMessage[] = [];
for await (const msg of reader.read()) {
messages.push(msg);
}
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(message1);
expect(messages[1]).toEqual(message2);
});
it('should skip empty lines and trim whitespace', async () => {
const input = new PassThrough();
const reader = new StreamJsonInputReader(input);
const message = {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [{ type: 'text', text: 'hello' }],
},
parent_tool_use_id: null,
};
input.write('\n');
input.write(' ' + JSON.stringify(message) + ' \n');
input.write(' \n');
input.write('\t\n');
input.end();
const messages: StreamJsonInputMessage[] = [];
for await (const msg of reader.read()) {
messages.push(msg);
}
expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(message);
});
/**
* Consolidated error handling test cases
*/
it.each([
{
name: 'invalid JSON',
input: '{"invalid": json}\n',
expectedError: 'Failed to parse stream-json line',
},
{
name: 'missing type field',
input:
JSON.stringify({ session_id: 'test-session', message: 'hello' }) +
'\n',
expectedError: 'Missing required "type" field',
},
{
name: 'non-object value (string)',
input: '"just a string"\n',
expectedError: 'Parsed value is not an object',
},
{
name: 'non-object value (null)',
input: 'null\n',
expectedError: 'Parsed value is not an object',
},
{
name: 'array value',
input: '[1, 2, 3]\n',
expectedError: 'Missing required "type" field',
},
{
name: 'type field not a string',
input: JSON.stringify({ type: 123, session_id: 'test-session' }) + '\n',
expectedError: 'Missing required "type" field',
},
])(
'should throw StreamJsonParseError for $name',
async ({ input: inputLine, expectedError }) => {
const input = new PassThrough();
const reader = new StreamJsonInputReader(input);
input.write(inputLine);
input.end();
const messages: StreamJsonInputMessage[] = [];
let error: unknown;
try {
for await (const msg of reader.read()) {
messages.push(msg);
}
} catch (e) {
error = e;
}
expect(messages).toHaveLength(0);
expect(error).toBeInstanceOf(StreamJsonParseError);
expect((error as StreamJsonParseError).message).toContain(
expectedError,
);
},
);
it('should use process.stdin as default input', () => {
const reader = new StreamJsonInputReader();
// Access private field for testing constructor default parameter
expect((reader as unknown as { input: typeof process.stdin }).input).toBe(
process.stdin,
);
});
it('should use provided input stream', () => {
const customInput = new PassThrough();
const reader = new StreamJsonInputReader(customInput);
// Access private field for testing constructor parameter
expect((reader as unknown as { input: typeof customInput }).input).toBe(
customInput,
);
});
});
});

View File

@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createInterface } from 'node:readline/promises';
import type { Readable } from 'node:stream';
import process from 'node:process';
import type {
CLIControlRequest,
CLIControlResponse,
CLIMessage,
ControlCancelRequest,
} from '../types.js';
export type StreamJsonInputMessage =
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest;
export class StreamJsonParseError extends Error {}
export class StreamJsonInputReader {
private readonly input: Readable;
constructor(input: Readable = process.stdin) {
this.input = input;
}
async *read(): AsyncGenerator<StreamJsonInputMessage> {
const rl = createInterface({
input: this.input,
crlfDelay: Number.POSITIVE_INFINITY,
terminal: false,
});
try {
for await (const rawLine of rl) {
const line = rawLine.trim();
if (!line) {
continue;
}
yield this.parse(line);
}
} finally {
rl.close();
}
}
private parse(line: string): StreamJsonInputMessage {
try {
const parsed = JSON.parse(line) as StreamJsonInputMessage;
if (!parsed || typeof parsed !== 'object') {
throw new StreamJsonParseError('Parsed value is not an object');
}
if (!('type' in parsed) || typeof parsed.type !== 'string') {
throw new StreamJsonParseError('Missing required "type" field');
}
return parsed;
} catch (error) {
if (error instanceof StreamJsonParseError) {
throw error;
}
const reason = error instanceof Error ? error.message : String(error);
throw new StreamJsonParseError(
`Failed to parse stream-json line: ${reason}`,
);
}
}
}

View File

@@ -0,0 +1,997 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type {
Config,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { GeminiEventType } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js';
function createMockConfig(): Config {
return {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getModel: vi.fn().mockReturnValue('test-model'),
} as unknown as Config;
}
describe('StreamJsonOutputAdapter', () => {
let adapter: StreamJsonOutputAdapter;
let mockConfig: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stdoutWriteSpy: any;
beforeEach(() => {
mockConfig = createMockConfig();
stdoutWriteSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutWriteSpy.mockRestore();
});
describe('with partial messages enabled', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
});
describe('startAssistantMessage', () => {
it('should reset state for new message', () => {
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'First',
});
adapter.finalizeAssistantMessage();
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Second',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'Second',
});
});
});
describe('processEvent with stream events', () => {
beforeEach(() => {
adapter.startAssistantMessage();
});
it('should emit stream events for text deltas', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Hello',
});
const calls = stdoutWriteSpy.mock.calls;
expect(calls.length).toBeGreaterThan(0);
const deltaEventCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta'
);
} catch {
return false;
}
});
expect(deltaEventCall).toBeDefined();
const parsed = JSON.parse(deltaEventCall![0] as string);
expect(parsed.event.type).toBe('content_block_delta');
expect(parsed.event.delta).toMatchObject({
type: 'text_delta',
text: 'Hello',
});
});
it('should emit message_start event on first content', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'First',
});
const calls = stdoutWriteSpy.mock.calls;
const messageStartCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'message_start'
);
} catch {
return false;
}
});
expect(messageStartCall).toBeDefined();
});
it('should emit content_block_start for new blocks', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
const calls = stdoutWriteSpy.mock.calls;
const blockStartCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_start'
);
} catch {
return false;
}
});
expect(blockStartCall).toBeDefined();
});
it('should emit thinking delta events', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking',
},
});
const calls = stdoutWriteSpy.mock.calls;
const deltaCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta' &&
parsed.event.delta.type === 'thinking_delta'
);
} catch {
return false;
}
});
expect(deltaCall).toBeDefined();
});
it('should emit message_stop on finalization', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
adapter.finalizeAssistantMessage();
const calls = stdoutWriteSpy.mock.calls;
const messageStopCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'message_stop'
);
} catch {
return false;
}
});
expect(messageStopCall).toBeDefined();
});
});
});
describe('with partial messages disabled', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should not emit stream events', () => {
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
const calls = stdoutWriteSpy.mock.calls;
const streamEventCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return parsed.type === 'stream_event';
} catch {
return false;
}
});
expect(streamEventCall).toBeUndefined();
});
it('should still emit final assistant message', () => {
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
adapter.finalizeAssistantMessage();
const calls = stdoutWriteSpy.mock.calls;
const assistantCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return parsed.type === 'assistant';
} catch {
return false;
}
});
expect(assistantCall).toBeDefined();
});
});
describe('processEvent', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
adapter.startAssistantMessage();
});
it('should append text content from Content events', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Hello',
});
adapter.processEvent({
type: GeminiEventType.Content,
value: ' World',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'Hello World',
});
});
it('should append citation content from Citation events', () => {
adapter.processEvent({
type: GeminiEventType.Citation,
value: 'Citation text',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('Citation text'),
});
});
it('should ignore non-string citation values', () => {
adapter.processEvent({
type: GeminiEventType.Citation,
value: 123,
} as unknown as ServerGeminiStreamEvent);
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(0);
});
it('should append thinking from Thought events', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking about the task',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'thinking',
thinking: 'Planning: Thinking about the task',
signature: 'Planning',
});
});
it('should handle thinking with only subject', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: '',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content[0]).toMatchObject({
type: 'thinking',
signature: 'Planning',
});
});
it('should append tool use from ToolCallRequest events', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'tool_use',
id: 'tool-call-1',
name: 'test_tool',
input: { param1: 'value1' },
});
});
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBe('tool_use');
});
it('should set stop_reason to null when message contains text blocks', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Some text',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBeNull();
});
it('should set stop_reason to null when message contains thinking blocks', () => {
adapter.processEvent({
type: GeminiEventType.Thought,
value: {
subject: 'Planning',
description: 'Thinking about the task',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.stop_reason).toBeNull();
});
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-1',
name: 'test_tool_1',
args: { param1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
adapter.processEvent({
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-call-2',
name: 'test_tool_2',
args: { param2: 'value2' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(2);
expect(
message.message.content.every((block) => block.type === 'tool_use'),
).toBe(true);
expect(message.message.stop_reason).toBe('tool_use');
});
it('should update usage from Finished event', () => {
const usageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 50,
cachedContentTokenCount: 10,
totalTokenCount: 160,
};
adapter.processEvent({
type: GeminiEventType.Finished,
value: {
reason: undefined,
usageMetadata,
},
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.usage).toMatchObject({
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 10,
total_tokens: 160,
});
});
it('should ignore events after finalization', () => {
adapter.finalizeAssistantMessage();
const originalContent =
adapter.finalizeAssistantMessage().message.content;
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Should be ignored',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toEqual(originalContent);
});
});
describe('finalizeAssistantMessage', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
adapter.startAssistantMessage();
});
it('should build and emit a complete assistant message', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test response',
});
const message = adapter.finalizeAssistantMessage();
expect(message.type).toBe('assistant');
expect(message.uuid).toBeTruthy();
expect(message.session_id).toBe('test-session-id');
expect(message.parent_tool_use_id).toBeNull();
expect(message.message.role).toBe('assistant');
expect(message.message.model).toBe('test-model');
expect(message.message.content).toHaveLength(1);
});
it('should emit message to stdout immediately', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test',
});
stdoutWriteSpy.mockClear();
adapter.finalizeAssistantMessage();
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('assistant');
});
it('should store message in lastAssistantMessage', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test',
});
const message = adapter.finalizeAssistantMessage();
// Access protected property for testing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((adapter as any).lastAssistantMessage).toEqual(message);
});
it('should return same message on subsequent calls', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Test',
});
const message1 = adapter.finalizeAssistantMessage();
const message2 = adapter.finalizeAssistantMessage();
expect(message1).toEqual(message2);
});
it('should split different block types into separate assistant messages', () => {
stdoutWriteSpy.mockClear();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: { subject: 'Thinking', description: 'Thought' },
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0].type).toBe('thinking');
const assistantMessages = stdoutWriteSpy.mock.calls
.map((call: unknown[]) => JSON.parse(call[0] as string))
.filter(
(
payload: unknown,
): payload is {
type: string;
message: { content: Array<{ type: string }> };
} => {
if (
typeof payload !== 'object' ||
payload === null ||
!('type' in payload) ||
(payload as { type?: string }).type !== 'assistant' ||
!('message' in payload)
) {
return false;
}
const message = (payload as { message?: unknown }).message;
if (
typeof message !== 'object' ||
message === null ||
!('content' in message)
) {
return false;
}
const content = (message as { content?: unknown }).content;
return (
Array.isArray(content) &&
content.length > 0 &&
content.every(
(block: unknown) =>
typeof block === 'object' &&
block !== null &&
'type' in block,
)
);
},
);
expect(assistantMessages).toHaveLength(2);
const observedTypes = assistantMessages.map(
(payload: {
type: string;
message: { content: Array<{ type: string }> };
}) => payload.message.content[0]?.type ?? '',
);
expect(observedTypes).toEqual(['text', 'thinking']);
for (const payload of assistantMessages) {
const uniqueTypes = new Set(
payload.message.content.map((block: { type: string }) => block.type),
);
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
}
});
it('should throw if message not started', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
expect(() => adapter.finalizeAssistantMessage()).toThrow(
'Message not started',
);
});
});
describe('emitResult', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
adapter.startAssistantMessage();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Response text',
});
adapter.finalizeAssistantMessage();
});
it('should emit success result immediately', () => {
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('result');
expect(parsed.is_error).toBe(false);
expect(parsed.subtype).toBe('success');
expect(parsed.result).toBe('Response text');
expect(parsed.duration_ms).toBe(1000);
expect(parsed.num_turns).toBe(1);
});
it('should emit error result', () => {
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: true,
errorMessage: 'Test error',
durationMs: 500,
apiDurationMs: 300,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.is_error).toBe(true);
expect(parsed.subtype).toBe('error_during_execution');
expect(parsed.error?.message).toBe('Test error');
});
it('should use provided summary over extracted text', () => {
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: false,
summary: 'Custom summary',
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.result).toBe('Custom summary');
});
it('should include usage information', () => {
const usage = {
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
};
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: false,
usage,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.usage).toEqual(usage);
});
it('should handle result without assistant message', () => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
stdoutWriteSpy.mockClear();
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.result).toBe('');
});
});
describe('emitUserMessage', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should emit user message immediately', () => {
stdoutWriteSpy.mockClear();
const parts: Part[] = [{ text: 'Hello user' }];
adapter.emitUserMessage(parts);
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('user');
expect(Array.isArray(parsed.message.content)).toBe(true);
if (Array.isArray(parsed.message.content)) {
expect(parsed.message.content).toHaveLength(1);
expect(parsed.message.content[0]).toEqual({
type: 'text',
text: 'Hello user',
});
}
});
it('should handle parent_tool_use_id', () => {
const parts: Part[] = [{ text: 'Tool response' }];
adapter.emitUserMessage(parts);
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
// emitUserMessage currently sets parent_tool_use_id to null
expect(parsed.parent_tool_use_id).toBeNull();
});
});
describe('emitToolResult', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should emit tool result message immediately', () => {
stdoutWriteSpy.mockClear();
const request = {
callId: 'tool-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const response = {
callId: 'tool-1',
responseParts: [],
resultDisplay: 'Tool executed successfully',
error: undefined,
errorType: undefined,
};
adapter.emitToolResult(request, response);
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('user');
expect(parsed.parent_tool_use_id).toBeNull();
const block = parsed.message.content[0];
expect(block).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'Tool executed successfully',
is_error: false,
});
});
it('should mark error tool results', () => {
const request = {
callId: 'tool-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const response = {
callId: 'tool-1',
responseParts: [],
resultDisplay: undefined,
error: new Error('Tool failed'),
errorType: undefined,
};
adapter.emitToolResult(request, response);
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
const block = parsed.message.content[0];
expect(block.is_error).toBe(true);
});
});
describe('emitSystemMessage', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should emit system message immediately', () => {
stdoutWriteSpy.mockClear();
adapter.emitSystemMessage('test_subtype', { data: 'value' });
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
const parsed = JSON.parse(output);
expect(parsed.type).toBe('system');
expect(parsed.subtype).toBe('test_subtype');
expect(parsed.data).toEqual({ data: 'value' });
});
});
describe('getSessionId and getModel', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
});
it('should return session ID from config', () => {
expect(adapter.getSessionId()).toBe('test-session-id');
expect(mockConfig.getSessionId).toHaveBeenCalled();
});
it('should return model from config', () => {
expect(adapter.getModel()).toBe('test-model');
expect(mockConfig.getModel).toHaveBeenCalled();
});
});
describe('message_id in stream events', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
adapter.startAssistantMessage();
});
it('should include message_id in stream events after message starts', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
// Process another event to ensure messageStarted is true
adapter.processEvent({
type: GeminiEventType.Content,
value: 'More',
});
const calls = stdoutWriteSpy.mock.calls;
// Find all delta events
const deltaCalls = calls.filter((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta'
);
} catch {
return false;
}
});
expect(deltaCalls.length).toBeGreaterThan(0);
// The second delta event should have message_id (after messageStarted becomes true)
// message_id is added to the event object, so check parsed.event.message_id
if (deltaCalls.length > 1) {
const secondDelta = JSON.parse(
(deltaCalls[1] as unknown[])[0] as string,
);
// message_id is on the enriched event object
expect(
secondDelta.event.message_id || secondDelta.message_id,
).toBeTruthy();
} else {
// If only one delta, check if message_id exists
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
// message_id is added when messageStarted is true
// First event may or may not have it, but subsequent ones should
expect(delta.event.message_id || delta.message_id).toBeTruthy();
}
});
});
describe('multiple text blocks', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, false);
adapter.startAssistantMessage();
});
it('should split assistant messages when block types change repeatedly', () => {
stdoutWriteSpy.mockClear();
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text content',
});
adapter.processEvent({
type: GeminiEventType.Thought,
value: { subject: 'Thinking', description: 'Thought' },
});
adapter.processEvent({
type: GeminiEventType.Content,
value: 'More text',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'More text',
});
const assistantMessages = stdoutWriteSpy.mock.calls
.map((call: unknown[]) => JSON.parse(call[0] as string))
.filter(
(
payload: unknown,
): payload is {
type: string;
message: { content: Array<{ type: string; text?: string }> };
} => {
if (
typeof payload !== 'object' ||
payload === null ||
!('type' in payload) ||
(payload as { type?: string }).type !== 'assistant' ||
!('message' in payload)
) {
return false;
}
const message = (payload as { message?: unknown }).message;
if (
typeof message !== 'object' ||
message === null ||
!('content' in message)
) {
return false;
}
const content = (message as { content?: unknown }).content;
return (
Array.isArray(content) &&
content.length > 0 &&
content.every(
(block: unknown) =>
typeof block === 'object' &&
block !== null &&
'type' in block,
)
);
},
);
expect(assistantMessages).toHaveLength(3);
const observedTypes = assistantMessages.map(
(msg: {
type: string;
message: { content: Array<{ type: string; text?: string }> };
}) => msg.message.content[0]?.type ?? '',
);
expect(observedTypes).toEqual(['text', 'thinking', 'text']);
for (const msg of assistantMessages) {
const uniqueTypes = new Set(
msg.message.content.map((block: { type: string }) => block.type),
);
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
}
});
it('should merge consecutive text fragments', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Hello',
});
adapter.processEvent({
type: GeminiEventType.Content,
value: ' ',
});
adapter.processEvent({
type: GeminiEventType.Content,
value: 'World',
});
const message = adapter.finalizeAssistantMessage();
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toMatchObject({
type: 'text',
text: 'Hello World',
});
});
});
});

View File

@@ -0,0 +1,300 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { Config } from '@qwen-code/qwen-code-core';
import type {
CLIAssistantMessage,
CLIMessage,
CLIPartialAssistantMessage,
ControlMessage,
StreamEvent,
TextBlock,
ThinkingBlock,
ToolUseBlock,
} from '../types.js';
import {
BaseJsonOutputAdapter,
type MessageState,
type ResultOptions,
type JsonOutputAdapterInterface,
} from './BaseJsonOutputAdapter.js';
/**
* Stream JSON output adapter that emits messages immediately
* as they are completed during the streaming process.
* Supports both main agent and subagent messages through distinct APIs.
*/
export class StreamJsonOutputAdapter
extends BaseJsonOutputAdapter
implements JsonOutputAdapterInterface
{
constructor(
config: Config,
private readonly includePartialMessages: boolean,
) {
super(config);
}
/**
* Emits message immediately to stdout (stream mode).
*/
protected emitMessageImpl(message: CLIMessage | ControlMessage): void {
// Track assistant messages for result generation
if (
typeof message === 'object' &&
message !== null &&
'type' in message &&
message.type === 'assistant'
) {
this.updateLastAssistantMessage(message as CLIAssistantMessage);
}
// Emit messages immediately in stream mode
process.stdout.write(`${JSON.stringify(message)}\n`);
}
/**
* Stream mode emits stream events when includePartialMessages is enabled.
*/
protected shouldEmitStreamEvents(): boolean {
return this.includePartialMessages;
}
finalizeAssistantMessage(): CLIAssistantMessage {
const state = this.mainAgentMessageState;
if (state.finalized) {
return this.buildMessage(null);
}
state.finalized = true;
this.finalizePendingBlocks(state, null);
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
(a, b) => a - b,
);
for (const index of orderedOpenBlocks) {
this.onBlockClosed(state, index, null);
this.closeBlock(state, index);
}
if (state.messageStarted && this.includePartialMessages) {
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
}
const message = this.buildMessage(null);
this.updateLastAssistantMessage(message);
this.emitMessageImpl(message);
return message;
}
emitResult(options: ResultOptions): void {
const resultMessage = this.buildResultMessage(
options,
this.lastAssistantMessage,
);
this.emitMessageImpl(resultMessage);
}
emitMessage(message: CLIMessage | ControlMessage): void {
// In stream mode, emit immediately
this.emitMessageImpl(message);
}
send(message: CLIMessage | ControlMessage): void {
this.emitMessage(message);
}
/**
* Overrides base class hook to emit stream event when text block is created.
*/
protected override onTextBlockCreated(
state: MessageState,
index: number,
block: TextBlock,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_start',
index,
content_block: block,
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when text is appended.
*/
protected override onTextAppended(
state: MessageState,
index: number,
fragment: string,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text: fragment },
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when thinking block is created.
*/
protected override onThinkingBlockCreated(
state: MessageState,
index: number,
block: ThinkingBlock,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_start',
index,
content_block: block,
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when thinking is appended.
*/
protected override onThinkingAppended(
state: MessageState,
index: number,
fragment: string,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_delta',
index,
delta: { type: 'thinking_delta', thinking: fragment },
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when tool_use block is created.
*/
protected override onToolUseBlockCreated(
state: MessageState,
index: number,
block: ToolUseBlock,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_start',
index,
content_block: block,
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when tool_use input is set.
*/
protected override onToolUseInputSet(
state: MessageState,
index: number,
input: unknown,
parentToolUseId: string | null,
): void {
this.emitStreamEventIfEnabled(
{
type: 'content_block_delta',
index,
delta: {
type: 'input_json_delta',
partial_json: JSON.stringify(input),
},
},
parentToolUseId,
);
}
/**
* Overrides base class hook to emit stream event when block is closed.
*/
protected override onBlockClosed(
state: MessageState,
index: number,
parentToolUseId: string | null,
): void {
if (this.includePartialMessages) {
this.emitStreamEventIfEnabled(
{
type: 'content_block_stop',
index,
},
parentToolUseId,
);
}
}
/**
* Overrides base class hook to emit message_start event when message is started.
* Only emits for main agent, not for subagents.
*/
protected override onEnsureMessageStarted(
state: MessageState,
parentToolUseId: string | null,
): void {
// Only emit message_start for main agent, not for subagents
if (parentToolUseId === null) {
this.emitStreamEventIfEnabled(
{
type: 'message_start',
message: {
id: state.messageId!,
role: 'assistant',
model: this.config.getModel(),
},
},
null,
);
}
}
/**
* Emits stream events when partial messages are enabled.
* This is a private method specific to StreamJsonOutputAdapter.
* @param event - Stream event to emit
* @param parentToolUseId - null for main agent, string for subagent
*/
private emitStreamEventIfEnabled(
event: StreamEvent,
parentToolUseId: string | null,
): void {
if (!this.includePartialMessages) {
return;
}
const state = this.getMessageState(parentToolUseId);
const enrichedEvent = state.messageStarted
? ({ ...event, message_id: state.messageId } as StreamEvent & {
message_id: string;
})
: event;
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: parentToolUseId,
event: enrichedEvent,
};
this.emitMessageImpl(partial);
}
}

View File

@@ -0,0 +1,591 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import { runNonInteractiveStreamJson } from './session.js';
import type {
CLIUserMessage,
CLIControlRequest,
CLIControlResponse,
ControlCancelRequest,
} from './types.js';
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
import { ControlDispatcher } from './control/ControlDispatcher.js';
import { ControlContext } from './control/ControlContext.js';
import { ControlService } from './control/ControlService.js';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
const runNonInteractiveMock = vi.fn();
// Mock dependencies
vi.mock('../nonInteractiveCli.js', () => ({
runNonInteractive: (...args: unknown[]) => runNonInteractiveMock(...args),
}));
vi.mock('./io/StreamJsonInputReader.js', () => ({
StreamJsonInputReader: vi.fn(),
}));
vi.mock('./io/StreamJsonOutputAdapter.js', () => ({
StreamJsonOutputAdapter: vi.fn(),
}));
vi.mock('./control/ControlDispatcher.js', () => ({
ControlDispatcher: vi.fn(),
}));
vi.mock('./control/ControlContext.js', () => ({
ControlContext: vi.fn(),
}));
vi.mock('./control/ControlService.js', () => ({
ControlService: vi.fn(),
}));
vi.mock('../ui/utils/ConsolePatcher.js', () => ({
ConsolePatcher: vi.fn(),
}));
interface ConfigOverrides {
getSessionId?: () => string;
getModel?: () => string;
getIncludePartialMessages?: () => boolean;
getDebugMode?: () => boolean;
getApprovalMode?: () => string;
getOutputFormat?: () => string;
[key: string]: unknown;
}
function createConfig(overrides: ConfigOverrides = {}): Config {
const base = {
getSessionId: () => 'test-session',
getModel: () => 'test-model',
getIncludePartialMessages: () => false,
getDebugMode: () => false,
getApprovalMode: () => 'auto',
getOutputFormat: () => 'stream-json',
};
return { ...base, ...overrides } as unknown as Config;
}
function createUserMessage(content: string): CLIUserMessage {
return {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content,
},
parent_tool_use_id: null,
};
}
function createControlRequest(
subtype: 'initialize' | 'set_model' | 'interrupt' = 'initialize',
): CLIControlRequest {
if (subtype === 'set_model') {
return {
type: 'control_request',
request_id: 'req-1',
request: {
subtype: 'set_model',
model: 'test-model',
},
};
}
if (subtype === 'interrupt') {
return {
type: 'control_request',
request_id: 'req-1',
request: {
subtype: 'interrupt',
},
};
}
return {
type: 'control_request',
request_id: 'req-1',
request: {
subtype: 'initialize',
},
};
}
function createControlResponse(requestId: string): CLIControlResponse {
return {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {},
},
};
}
function createControlCancel(requestId: string): ControlCancelRequest {
return {
type: 'control_cancel_request',
request_id: requestId,
};
}
describe('runNonInteractiveStreamJson', () => {
let config: Config;
let mockInputReader: {
read: () => AsyncGenerator<
| CLIUserMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest
>;
};
let mockOutputAdapter: {
emitResult: ReturnType<typeof vi.fn>;
};
let mockDispatcher: {
dispatch: ReturnType<typeof vi.fn>;
handleControlResponse: ReturnType<typeof vi.fn>;
handleCancel: ReturnType<typeof vi.fn>;
shutdown: ReturnType<typeof vi.fn>;
};
let mockConsolePatcher: {
patch: ReturnType<typeof vi.fn>;
cleanup: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
config = createConfig();
runNonInteractiveMock.mockReset();
// Setup mocks
mockConsolePatcher = {
patch: vi.fn(),
cleanup: vi.fn(),
};
(ConsolePatcher as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => mockConsolePatcher,
);
mockOutputAdapter = {
emitResult: vi.fn(),
} as {
emitResult: ReturnType<typeof vi.fn>;
[key: string]: unknown;
};
(
StreamJsonOutputAdapter as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockOutputAdapter);
mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
handleControlResponse: vi.fn(),
handleCancel: vi.fn(),
shutdown: vi.fn(),
};
(
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockDispatcher);
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({}),
);
(ControlService as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({}),
);
mockInputReader = {
async *read() {
// Default: empty stream
// Override in tests as needed
},
};
(
StreamJsonInputReader as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockInputReader);
runNonInteractiveMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('initializes session and processes initialize control request', async () => {
const initRequest = createControlRequest('initialize');
mockInputReader.read = async function* () {
yield initRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
});
it('processes user message when received as first message', async () => {
const userMessage = createUserMessage('Hello world');
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
const runCall = runNonInteractiveMock.mock.calls[0];
expect(runCall[2]).toBe('Hello world'); // Direct text, not processed
expect(typeof runCall[3]).toBe('string'); // promptId
expect(runCall[4]).toEqual(
expect.objectContaining({
abortController: expect.any(AbortController),
adapter: mockOutputAdapter,
}),
);
});
it('processes multiple user messages sequentially', async () => {
// Initialize first to enable multi-query mode
const initRequest = createControlRequest('initialize');
const userMessage1 = createUserMessage('First message');
const userMessage2 = createUserMessage('Second message');
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage1;
yield userMessage2;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
});
it('enqueues user messages received during processing', async () => {
const initRequest = createControlRequest('initialize');
const userMessage1 = createUserMessage('First message');
const userMessage2 = createUserMessage('Second message');
// Make runNonInteractive take some time to simulate processing
runNonInteractiveMock.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 10)),
);
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage1;
yield userMessage2;
};
await runNonInteractiveStreamJson(config, '');
// Both messages should be processed
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
});
it('processes control request in idle state', async () => {
const initRequest = createControlRequest('initialize');
const controlRequest = createControlRequest('set_model');
mockInputReader.read = async function* () {
yield initRequest;
yield controlRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(2);
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(1, initRequest);
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(2, controlRequest);
});
it('handles control response in idle state', async () => {
const initRequest = createControlRequest('initialize');
const controlResponse = createControlResponse('req-2');
mockInputReader.read = async function* () {
yield initRequest;
yield controlResponse;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
controlResponse,
);
});
it('handles control cancel in idle state', async () => {
const initRequest = createControlRequest('initialize');
const cancelRequest = createControlCancel('req-2');
mockInputReader.read = async function* () {
yield initRequest;
yield cancelRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleCancel).toHaveBeenCalledWith('req-2');
});
it('handles control request during processing state', async () => {
const initRequest = createControlRequest('initialize');
const userMessage = createUserMessage('Process me');
const controlRequest = createControlRequest('set_model');
runNonInteractiveMock.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 10)),
);
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage;
yield controlRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(controlRequest);
});
it('handles control response during processing state', async () => {
const initRequest = createControlRequest('initialize');
const userMessage = createUserMessage('Process me');
const controlResponse = createControlResponse('req-1');
runNonInteractiveMock.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 10)),
);
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage;
yield controlResponse;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
controlResponse,
);
});
it('handles user message with text content', async () => {
const userMessage = createUserMessage('Test message');
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
expect(runNonInteractiveMock).toHaveBeenCalledWith(
config,
expect.objectContaining({ merged: expect.any(Object) }),
'Test message',
expect.stringContaining('test-session'),
expect.objectContaining({
abortController: expect.any(AbortController),
adapter: mockOutputAdapter,
}),
);
});
it('handles user message with array content blocks', async () => {
const userMessage: CLIUserMessage = {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [
{ type: 'text', text: 'First part' },
{ type: 'text', text: 'Second part' },
],
},
parent_tool_use_id: null,
};
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
expect(runNonInteractiveMock).toHaveBeenCalledWith(
config,
expect.objectContaining({ merged: expect.any(Object) }),
'First part\nSecond part',
expect.stringContaining('test-session'),
expect.objectContaining({
abortController: expect.any(AbortController),
adapter: mockOutputAdapter,
}),
);
});
it('skips user message with no text content', async () => {
const userMessage: CLIUserMessage = {
type: 'user',
session_id: 'test-session',
message: {
role: 'user',
content: [],
},
parent_tool_use_id: null,
};
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).not.toHaveBeenCalled();
});
it('handles error from processUserMessage', async () => {
const userMessage = createUserMessage('Test message');
const error = new Error('Processing error');
runNonInteractiveMock.mockRejectedValue(error);
mockInputReader.read = async function* () {
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
// Error should be caught and handled gracefully
});
it('handles stream error gracefully', async () => {
const streamError = new Error('Stream error');
// eslint-disable-next-line require-yield
mockInputReader.read = async function* () {
throw streamError;
} as typeof mockInputReader.read;
await expect(runNonInteractiveStreamJson(config, '')).rejects.toThrow(
'Stream error',
);
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
});
it('stops processing when abort signal is triggered', async () => {
const initRequest = createControlRequest('initialize');
const userMessage = createUserMessage('Test message');
// Capture abort signal from ControlContext
let abortSignal: AbortSignal | null = null;
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(options: { abortSignal?: AbortSignal }) => {
abortSignal = options.abortSignal ?? null;
return {};
},
);
// Create input reader that aborts after first message
mockInputReader.read = async function* () {
yield initRequest;
// Abort the signal after initialization
if (abortSignal && !abortSignal.aborted) {
// The signal doesn't have an abort method, but the controller does
// Since we can't access the controller directly, we'll test by
// verifying that cleanup happens properly
}
// Yield second message - if abort works, it should be checked
yield userMessage;
};
await runNonInteractiveStreamJson(config, '');
// Verify initialization happened
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
expect(mockDispatcher.shutdown).toHaveBeenCalled();
});
it('generates unique prompt IDs for each message', async () => {
// Initialize first to enable multi-query mode
const initRequest = createControlRequest('initialize');
const userMessage1 = createUserMessage('First');
const userMessage2 = createUserMessage('Second');
mockInputReader.read = async function* () {
yield initRequest;
yield userMessage1;
yield userMessage2;
};
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
const promptId1 = runNonInteractiveMock.mock.calls[0][3] as string;
const promptId2 = runNonInteractiveMock.mock.calls[1][3] as string;
expect(promptId1).not.toBe(promptId2);
expect(promptId1).toContain('test-session');
expect(promptId2).toContain('test-session');
});
it('ignores non-initialize control request during initialization', async () => {
const controlRequest = createControlRequest('set_model');
mockInputReader.read = async function* () {
yield controlRequest;
};
await runNonInteractiveStreamJson(config, '');
// Should not transition to idle since it's not an initialize request
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
});
it('cleans up console patcher on completion', async () => {
mockInputReader.read = async function* () {
// Empty stream - should complete immediately
};
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
});
it('cleans up output adapter on completion', async () => {
mockInputReader.read = async function* () {
// Empty stream
};
await runNonInteractiveStreamJson(config, '');
});
it('calls dispatcher shutdown on completion', async () => {
const initRequest = createControlRequest('initialize');
mockInputReader.read = async function* () {
yield initRequest;
};
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.shutdown).toHaveBeenCalledTimes(1);
});
it('handles empty stream gracefully', async () => {
mockInputReader.read = async function* () {
// Empty stream
};
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,721 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Stream JSON Runner with Session State Machine
*
* Handles stream-json input/output format with:
* - Initialize handshake
* - Message routing (control vs user messages)
* - FIFO user message queue
* - Sequential message processing
* - Graceful shutdown
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
import { ControlContext } from './control/ControlContext.js';
import { ControlDispatcher } from './control/ControlDispatcher.js';
import { ControlService } from './control/ControlService.js';
import type {
CLIMessage,
CLIUserMessage,
CLIControlRequest,
CLIControlResponse,
ControlCancelRequest,
} from './types.js';
import {
isCLIUserMessage,
isCLIAssistantMessage,
isCLISystemMessage,
isCLIResultMessage,
isCLIPartialAssistantMessage,
isControlRequest,
isControlResponse,
isControlCancel,
} from './types.js';
import { createMinimalSettings } from '../config/settings.js';
import { runNonInteractive } from '../nonInteractiveCli.js';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
const SESSION_STATE = {
INITIALIZING: 'initializing',
IDLE: 'idle',
PROCESSING_QUERY: 'processing_query',
SHUTTING_DOWN: 'shutting_down',
} as const;
type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE];
/**
* Message type classification for routing
*/
type MessageType =
| 'control_request'
| 'control_response'
| 'control_cancel'
| 'user'
| 'assistant'
| 'system'
| 'result'
| 'stream_event'
| 'unknown';
/**
* Routed message with classification
*/
interface RoutedMessage {
type: MessageType;
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest;
}
/**
* Session Manager
*
* Manages the session lifecycle and message processing state machine.
*/
class SessionManager {
private state: SessionState = SESSION_STATE.INITIALIZING;
private userMessageQueue: CLIUserMessage[] = [];
private abortController: AbortController;
private config: Config;
private sessionId: string;
private promptIdCounter: number = 0;
private inputReader: StreamJsonInputReader;
private outputAdapter: StreamJsonOutputAdapter;
private controlContext: ControlContext | null = null;
private dispatcher: ControlDispatcher | null = null;
private controlService: ControlService | null = null;
private controlSystemEnabled: boolean | null = null;
private debugMode: boolean;
private shutdownHandler: (() => void) | null = null;
private initialPrompt: CLIUserMessage | null = null;
constructor(config: Config, initialPrompt?: CLIUserMessage) {
this.config = config;
this.sessionId = config.getSessionId();
this.debugMode = config.getDebugMode();
this.abortController = new AbortController();
this.initialPrompt = initialPrompt ?? null;
this.inputReader = new StreamJsonInputReader();
this.outputAdapter = new StreamJsonOutputAdapter(
config,
config.getIncludePartialMessages(),
);
// Setup signal handlers for graceful shutdown
this.setupSignalHandlers();
}
/**
* Get next prompt ID
*/
private getNextPromptId(): string {
this.promptIdCounter++;
return `${this.sessionId}########${this.promptIdCounter}`;
}
/**
* Route a message to the appropriate handler based on its type
*
* Classifies incoming messages and routes them to appropriate handlers.
*/
private route(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): RoutedMessage {
// Check control messages first
if (isControlRequest(message)) {
return { type: 'control_request', message };
}
if (isControlResponse(message)) {
return { type: 'control_response', message };
}
if (isControlCancel(message)) {
return { type: 'control_cancel', message };
}
// Check data messages
if (isCLIUserMessage(message)) {
return { type: 'user', message };
}
if (isCLIAssistantMessage(message)) {
return { type: 'assistant', message };
}
if (isCLISystemMessage(message)) {
return { type: 'system', message };
}
if (isCLIResultMessage(message)) {
return { type: 'result', message };
}
if (isCLIPartialAssistantMessage(message)) {
return { type: 'stream_event', message };
}
// Unknown message type
if (this.debugMode) {
console.error(
'[SessionManager] Unknown message type:',
JSON.stringify(message, null, 2),
);
}
return { type: 'unknown', message };
}
/**
* Process a single message with unified logic for both initial prompt and stream messages.
*
* Handles:
* - Abort check
* - First message detection and handling
* - Normal message processing
* - Shutdown state checks
*
* @param message - Message to process
* @returns true if the calling code should exit (break/return), false to continue
*/
private async processSingleMessage(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): Promise<boolean> {
// Check for abort
if (this.abortController.signal.aborted) {
return true;
}
// Handle first message if control system not yet initialized
if (this.controlSystemEnabled === null) {
const handled = await this.handleFirstMessage(message);
if (handled) {
// If handled, check if we should shutdown
return this.state === SESSION_STATE.SHUTTING_DOWN;
}
// If not handled, fall through to normal processing
}
// Process message normally
await this.processMessage(message);
// Check for shutdown after processing
return this.state === SESSION_STATE.SHUTTING_DOWN;
}
/**
* Main entry point - run the session
*/
async run(): Promise<void> {
try {
if (this.debugMode) {
console.error('[SessionManager] Starting session', this.sessionId);
}
// Process initial prompt if provided
if (this.initialPrompt !== null) {
const shouldExit = await this.processSingleMessage(this.initialPrompt);
if (shouldExit) {
await this.shutdown();
return;
}
}
// Process messages from stream
for await (const message of this.inputReader.read()) {
const shouldExit = await this.processSingleMessage(message);
if (shouldExit) {
break;
}
}
// Stream closed, shutdown
await this.shutdown();
} catch (error) {
if (this.debugMode) {
console.error('[SessionManager] Error:', error);
}
await this.shutdown();
throw error;
} finally {
// Ensure signal handlers are always cleaned up even if shutdown wasn't called
this.cleanupSignalHandlers();
}
}
private ensureControlSystem(): void {
if (this.controlContext && this.dispatcher && this.controlService) {
return;
}
// The control system follows a strict three-layer architecture:
// 1. ControlContext (shared session state)
// 2. ControlDispatcher (protocol routing SDK ↔ CLI)
// 3. ControlService (programmatic API for CLI runtime)
//
// Application code MUST interact with the control plane exclusively through
// ControlService. ControlDispatcher is reserved for protocol-level message
// routing and should never be used directly outside of this file.
this.controlContext = new ControlContext({
config: this.config,
streamJson: this.outputAdapter,
sessionId: this.sessionId,
abortSignal: this.abortController.signal,
permissionMode: this.config.getApprovalMode(),
onInterrupt: () => this.handleInterrupt(),
});
this.dispatcher = new ControlDispatcher(this.controlContext);
this.controlService = new ControlService(
this.controlContext,
this.dispatcher,
);
}
private getDispatcher(): ControlDispatcher | null {
if (this.controlSystemEnabled !== true) {
return null;
}
if (!this.dispatcher) {
this.ensureControlSystem();
}
return this.dispatcher;
}
private async handleFirstMessage(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): Promise<boolean> {
const routed = this.route(message);
if (routed.type === 'control_request') {
const request = routed.message as CLIControlRequest;
this.controlSystemEnabled = true;
this.ensureControlSystem();
if (request.request.subtype === 'initialize') {
await this.dispatcher?.dispatch(request);
this.state = SESSION_STATE.IDLE;
return true;
}
return false;
}
if (routed.type === 'user') {
this.controlSystemEnabled = false;
this.state = SESSION_STATE.PROCESSING_QUERY;
this.userMessageQueue.push(routed.message as CLIUserMessage);
await this.processUserMessageQueue();
return true;
}
this.controlSystemEnabled = false;
return false;
}
/**
* Process a single message from the stream
*/
private async processMessage(
message:
| CLIMessage
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest,
): Promise<void> {
const routed = this.route(message);
if (this.debugMode) {
console.error(
`[SessionManager] State: ${this.state}, Message type: ${routed.type}`,
);
}
switch (this.state) {
case SESSION_STATE.INITIALIZING:
await this.handleInitializingState(routed);
break;
case SESSION_STATE.IDLE:
await this.handleIdleState(routed);
break;
case SESSION_STATE.PROCESSING_QUERY:
await this.handleProcessingState(routed);
break;
case SESSION_STATE.SHUTTING_DOWN:
// Ignore all messages during shutdown
break;
default: {
// Exhaustive check
const _exhaustiveCheck: never = this.state;
if (this.debugMode) {
console.error('[SessionManager] Unknown state:', _exhaustiveCheck);
}
break;
}
}
}
/**
* Handle messages in initializing state
*/
private async handleInitializingState(routed: RoutedMessage): Promise<void> {
if (routed.type === 'control_request') {
const request = routed.message as CLIControlRequest;
const dispatcher = this.getDispatcher();
if (!dispatcher) {
if (this.debugMode) {
console.error(
'[SessionManager] Control request received before control system initialization',
);
}
return;
}
if (request.request.subtype === 'initialize') {
await dispatcher.dispatch(request);
this.state = SESSION_STATE.IDLE;
if (this.debugMode) {
console.error('[SessionManager] Initialized, transitioning to idle');
}
} else {
if (this.debugMode) {
console.error(
'[SessionManager] Ignoring non-initialize control request during initialization',
);
}
}
} else {
if (this.debugMode) {
console.error(
'[SessionManager] Ignoring non-control message during initialization',
);
}
}
}
/**
* Handle messages in idle state
*/
private async handleIdleState(routed: RoutedMessage): Promise<void> {
const dispatcher = this.getDispatcher();
if (routed.type === 'control_request') {
if (!dispatcher) {
if (this.debugMode) {
console.error('[SessionManager] Ignoring control request (disabled)');
}
return;
}
const request = routed.message as CLIControlRequest;
await dispatcher.dispatch(request);
// Stay in idle state
} else if (routed.type === 'control_response') {
if (!dispatcher) {
return;
}
const response = routed.message as CLIControlResponse;
dispatcher.handleControlResponse(response);
// Stay in idle state
} else if (routed.type === 'control_cancel') {
if (!dispatcher) {
return;
}
const cancelRequest = routed.message as ControlCancelRequest;
dispatcher.handleCancel(cancelRequest.request_id);
} else if (routed.type === 'user') {
const userMessage = routed.message as CLIUserMessage;
this.userMessageQueue.push(userMessage);
// Start processing queue
await this.processUserMessageQueue();
} else {
if (this.debugMode) {
console.error(
'[SessionManager] Ignoring message type in idle state:',
routed.type,
);
}
}
}
/**
* Handle messages in processing state
*/
private async handleProcessingState(routed: RoutedMessage): Promise<void> {
const dispatcher = this.getDispatcher();
if (routed.type === 'control_request') {
if (!dispatcher) {
if (this.debugMode) {
console.error(
'[SessionManager] Control request ignored during processing (disabled)',
);
}
return;
}
const request = routed.message as CLIControlRequest;
await dispatcher.dispatch(request);
// Continue processing
} else if (routed.type === 'control_response') {
if (!dispatcher) {
return;
}
const response = routed.message as CLIControlResponse;
dispatcher.handleControlResponse(response);
// Continue processing
} else if (routed.type === 'user') {
// Enqueue for later
const userMessage = routed.message as CLIUserMessage;
this.userMessageQueue.push(userMessage);
if (this.debugMode) {
console.error(
'[SessionManager] Enqueued user message during processing',
);
}
} else {
if (this.debugMode) {
console.error(
'[SessionManager] Ignoring message type during processing:',
routed.type,
);
}
}
}
/**
* Process user message queue (FIFO)
*/
private async processUserMessageQueue(): Promise<void> {
while (
this.userMessageQueue.length > 0 &&
!this.abortController.signal.aborted
) {
this.state = SESSION_STATE.PROCESSING_QUERY;
const userMessage = this.userMessageQueue.shift()!;
try {
await this.processUserMessage(userMessage);
} catch (error) {
if (this.debugMode) {
console.error(
'[SessionManager] Error processing user message:',
error,
);
}
// Send error result
this.emitErrorResult(error);
}
}
// If control system is disabled (single-query mode) and queue is empty,
// automatically shutdown instead of returning to idle
if (
!this.abortController.signal.aborted &&
this.state === SESSION_STATE.PROCESSING_QUERY &&
this.controlSystemEnabled === false &&
this.userMessageQueue.length === 0
) {
if (this.debugMode) {
console.error(
'[SessionManager] Single-query mode: queue processed, shutting down',
);
}
this.state = SESSION_STATE.SHUTTING_DOWN;
return;
}
// Return to idle after processing queue (for multi-query mode with control system)
if (
!this.abortController.signal.aborted &&
this.state === SESSION_STATE.PROCESSING_QUERY
) {
this.state = SESSION_STATE.IDLE;
if (this.debugMode) {
console.error('[SessionManager] Queue processed, returning to idle');
}
}
}
/**
* Process a single user message
*/
private async processUserMessage(userMessage: CLIUserMessage): Promise<void> {
const input = extractUserMessageText(userMessage);
if (!input) {
if (this.debugMode) {
console.error('[SessionManager] No text content in user message');
}
return;
}
const promptId = this.getNextPromptId();
try {
await runNonInteractive(
this.config,
createMinimalSettings(),
input,
promptId,
{
abortController: this.abortController,
adapter: this.outputAdapter,
controlService: this.controlService ?? undefined,
},
);
} catch (error) {
// Error already handled by runNonInteractive via adapter.emitResult
if (this.debugMode) {
console.error('[SessionManager] Query execution error:', error);
}
}
}
/**
* Send tool results as user message
*/
private emitErrorResult(
error: unknown,
numTurns: number = 0,
durationMs: number = 0,
apiDurationMs: number = 0,
): void {
const message = error instanceof Error ? error.message : String(error);
this.outputAdapter.emitResult({
isError: true,
errorMessage: message,
durationMs,
apiDurationMs,
numTurns,
usage: undefined,
});
}
/**
* Handle interrupt control request
*/
private handleInterrupt(): void {
if (this.debugMode) {
console.error('[SessionManager] Interrupt requested');
}
// Abort current query if processing
if (this.state === SESSION_STATE.PROCESSING_QUERY) {
this.abortController.abort();
this.abortController = new AbortController(); // Create new controller for next query
}
}
/**
* Setup signal handlers for graceful shutdown
*/
private setupSignalHandlers(): void {
this.shutdownHandler = () => {
if (this.debugMode) {
console.error('[SessionManager] Shutdown signal received');
}
this.abortController.abort();
this.state = SESSION_STATE.SHUTTING_DOWN;
};
process.on('SIGINT', this.shutdownHandler);
process.on('SIGTERM', this.shutdownHandler);
}
/**
* Shutdown session and cleanup resources
*/
private async shutdown(): Promise<void> {
if (this.debugMode) {
console.error('[SessionManager] Shutting down');
}
this.state = SESSION_STATE.SHUTTING_DOWN;
this.dispatcher?.shutdown();
this.cleanupSignalHandlers();
}
/**
* Remove signal handlers to prevent memory leaks
*/
private cleanupSignalHandlers(): void {
if (this.shutdownHandler) {
process.removeListener('SIGINT', this.shutdownHandler);
process.removeListener('SIGTERM', this.shutdownHandler);
this.shutdownHandler = null;
}
}
}
function extractUserMessageText(message: CLIUserMessage): string | null {
const content = message.message.content;
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
const parts = content
.map((block) => {
if (!block || typeof block !== 'object') {
return '';
}
if ('type' in block && block.type === 'text' && 'text' in block) {
return typeof block.text === 'string' ? block.text : '';
}
return JSON.stringify(block);
})
.filter((part) => part.length > 0);
return parts.length > 0 ? parts.join('\n') : null;
}
return null;
}
/**
* Entry point for stream-json mode
*
* @param config - Configuration object
* @param input - Optional initial prompt input to process before reading from stream
*/
export async function runNonInteractiveStreamJson(
config: Config,
input: string,
): Promise<void> {
const consolePatcher = new ConsolePatcher({
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
try {
// Create initial user message from prompt input if provided
let initialPrompt: CLIUserMessage | undefined = undefined;
if (input && input.trim().length > 0) {
const sessionId = config.getSessionId();
initialPrompt = {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: input.trim(),
},
parent_tool_use_id: null,
};
}
const manager = new SessionManager(config, initialPrompt);
await manager.run();
} finally {
consolePatcher.cleanup();
}
}

View File

@@ -0,0 +1,509 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Annotation for attaching metadata to content blocks
*/
export interface Annotation {
type: string;
value: string;
}
/**
* Usage information types
*/
export interface Usage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
total_tokens?: number;
}
export interface ExtendedUsage extends Usage {
server_tool_use?: {
web_search_requests: number;
};
service_tier?: string;
cache_creation?: {
ephemeral_1h_input_tokens: number;
ephemeral_5m_input_tokens: number;
};
}
export interface ModelUsage {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
webSearchRequests: number;
contextWindow: number;
}
/**
* Permission denial information
*/
export interface CLIPermissionDenial {
tool_name: string;
tool_use_id: string;
tool_input: unknown;
}
/**
* Content block types from Anthropic SDK
*/
export interface TextBlock {
type: 'text';
text: string;
annotations?: Annotation[];
}
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
annotations?: Annotation[];
}
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
input: unknown;
annotations?: Annotation[];
}
export interface ToolResultBlock {
type: 'tool_result';
tool_use_id: string;
content?: string | ContentBlock[];
is_error?: boolean;
annotations?: Annotation[];
}
export type ContentBlock =
| TextBlock
| ThinkingBlock
| ToolUseBlock
| ToolResultBlock;
/**
* Anthropic SDK Message types
*/
export interface APIUserMessage {
role: 'user';
content: string | ContentBlock[];
}
export interface APIAssistantMessage {
id: string;
type: 'message';
role: 'assistant';
model: string;
content: ContentBlock[];
stop_reason?: string | null;
usage: Usage;
}
/**
* CLI Message wrapper types
*/
export interface CLIUserMessage {
type: 'user';
uuid?: string;
session_id: string;
message: APIUserMessage;
parent_tool_use_id: string | null;
options?: Record<string, unknown>;
}
export interface CLIAssistantMessage {
type: 'assistant';
uuid: string;
session_id: string;
message: APIAssistantMessage;
parent_tool_use_id: string | null;
}
export interface CLISystemMessage {
type: 'system';
subtype: string;
uuid: string;
session_id: string;
data?: unknown;
cwd?: string;
tools?: string[];
mcp_servers?: Array<{
name: string;
status: string;
}>;
model?: string;
permissionMode?: string;
slash_commands?: string[];
apiKeySource?: string;
qwen_code_version?: string;
output_style?: string;
agents?: string[];
skills?: string[];
capabilities?: Record<string, unknown>;
compact_metadata?: {
trigger: 'manual' | 'auto';
pre_tokens: number;
};
}
export interface CLIResultMessageSuccess {
type: 'result';
subtype: 'success';
uuid: string;
session_id: string;
is_error: false;
duration_ms: number;
duration_api_ms: number;
num_turns: number;
result: string;
usage: ExtendedUsage;
modelUsage?: Record<string, ModelUsage>;
permission_denials: CLIPermissionDenial[];
[key: string]: unknown;
}
export interface CLIResultMessageError {
type: 'result';
subtype: 'error_max_turns' | 'error_during_execution';
uuid: string;
session_id: string;
is_error: true;
duration_ms: number;
duration_api_ms: number;
num_turns: number;
usage: ExtendedUsage;
modelUsage?: Record<string, ModelUsage>;
permission_denials: CLIPermissionDenial[];
error?: {
type?: string;
message: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError;
/**
* Stream event types for real-time message updates
*/
export interface MessageStartStreamEvent {
type: 'message_start';
message: {
id: string;
role: 'assistant';
model: string;
};
}
export interface ContentBlockStartEvent {
type: 'content_block_start';
index: number;
content_block: ContentBlock;
}
export type ContentBlockDelta =
| {
type: 'text_delta';
text: string;
}
| {
type: 'thinking_delta';
thinking: string;
}
| {
type: 'input_json_delta';
partial_json: string;
};
export interface ContentBlockDeltaEvent {
type: 'content_block_delta';
index: number;
delta: ContentBlockDelta;
}
export interface ContentBlockStopEvent {
type: 'content_block_stop';
index: number;
}
export interface MessageStopStreamEvent {
type: 'message_stop';
}
export type StreamEvent =
| MessageStartStreamEvent
| ContentBlockStartEvent
| ContentBlockDeltaEvent
| ContentBlockStopEvent
| MessageStopStreamEvent;
export interface CLIPartialAssistantMessage {
type: 'stream_event';
uuid: string;
session_id: string;
event: StreamEvent;
parent_tool_use_id: string | null;
}
export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo';
/**
* Permission suggestion for tool use requests
* TODO: Align with `ToolCallConfirmationDetails`
*/
export interface PermissionSuggestion {
type: 'allow' | 'deny' | 'modify';
label: string;
description?: string;
modifiedInput?: unknown;
}
/**
* Hook callback placeholder for future implementation
*/
export interface HookRegistration {
event: string;
callback_id: string;
}
/**
* Hook callback result placeholder for future implementation
*/
export interface HookCallbackResult {
shouldSkip?: boolean;
shouldInterrupt?: boolean;
suppressOutput?: boolean;
message?: string;
}
export interface CLIControlInterruptRequest {
subtype: 'interrupt';
}
export interface CLIControlPermissionRequest {
subtype: 'can_use_tool';
tool_name: string;
tool_use_id: string;
input: unknown;
permission_suggestions: PermissionSuggestion[] | null;
blocked_path: string | null;
}
export interface CLIControlInitializeRequest {
subtype: 'initialize';
hooks?: HookRegistration[] | null;
sdkMcpServers?: string[];
}
export interface CLIControlSetPermissionModeRequest {
subtype: 'set_permission_mode';
mode: PermissionMode;
}
export interface CLIHookCallbackRequest {
subtype: 'hook_callback';
callback_id: string;
input: unknown;
tool_use_id: string | null;
}
export interface CLIControlMcpMessageRequest {
subtype: 'mcp_message';
server_name: string;
message: {
jsonrpc?: string;
method: string;
params?: Record<string, unknown>;
id?: string | number | null;
};
}
export interface CLIControlSetModelRequest {
subtype: 'set_model';
model: string;
}
export interface CLIControlMcpStatusRequest {
subtype: 'mcp_server_status';
}
export interface CLIControlSupportedCommandsRequest {
subtype: 'supported_commands';
}
export type ControlRequestPayload =
| CLIControlInterruptRequest
| CLIControlPermissionRequest
| CLIControlInitializeRequest
| CLIControlSetPermissionModeRequest
| CLIHookCallbackRequest
| CLIControlMcpMessageRequest
| CLIControlSetModelRequest
| CLIControlMcpStatusRequest
| CLIControlSupportedCommandsRequest;
export interface CLIControlRequest {
type: 'control_request';
request_id: string;
request: ControlRequestPayload;
}
/**
* Permission approval result
*/
export interface PermissionApproval {
allowed: boolean;
reason?: string;
modifiedInput?: unknown;
}
export interface ControlResponse {
subtype: 'success';
request_id: string;
response: unknown;
}
export interface ControlErrorResponse {
subtype: 'error';
request_id: string;
error: string | { message: string; [key: string]: unknown };
}
export interface CLIControlResponse {
type: 'control_response';
response: ControlResponse | ControlErrorResponse;
}
export interface ControlCancelRequest {
type: 'control_cancel_request';
request_id?: string;
}
export type ControlMessage =
| CLIControlRequest
| CLIControlResponse
| ControlCancelRequest;
/**
* Union of all CLI message types
*/
export type CLIMessage =
| CLIUserMessage
| CLIAssistantMessage
| CLISystemMessage
| CLIResultMessage
| CLIPartialAssistantMessage;
/**
* Type guard functions for message discrimination
*/
export function isCLIUserMessage(msg: any): msg is CLIUserMessage {
return (
msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg
);
}
export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'assistant' &&
'uuid' in msg &&
'message' in msg &&
'session_id' in msg &&
'parent_tool_use_id' in msg
);
}
export function isCLISystemMessage(msg: any): msg is CLISystemMessage {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'system' &&
'subtype' in msg &&
'uuid' in msg &&
'session_id' in msg
);
}
export function isCLIResultMessage(msg: any): msg is CLIResultMessage {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'result' &&
'subtype' in msg &&
'duration_ms' in msg &&
'is_error' in msg &&
'uuid' in msg &&
'session_id' in msg
);
}
export function isCLIPartialAssistantMessage(
msg: any,
): msg is CLIPartialAssistantMessage {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'stream_event' &&
'uuid' in msg &&
'session_id' in msg &&
'event' in msg &&
'parent_tool_use_id' in msg
);
}
export function isControlRequest(msg: any): msg is CLIControlRequest {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'control_request' &&
'request_id' in msg &&
'request' in msg
);
}
export function isControlResponse(msg: any): msg is CLIControlResponse {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'control_response' &&
'response' in msg
);
}
export function isControlCancel(msg: any): msg is ControlCancelRequest {
return (
msg &&
typeof msg === 'object' &&
msg.type === 'control_cancel_request' &&
'request_id' in msg
);
}
/**
* Content block type guards
*/
export function isTextBlock(block: any): block is TextBlock {
return block && typeof block === 'object' && block.type === 'text';
}
export function isThinkingBlock(block: any): block is ThinkingBlock {
return block && typeof block === 'object' && block.type === 'thinking';
}
export function isToolUseBlock(block: any): block is ToolUseBlock {
return block && typeof block === 'object' && block.type === 'tool_use';
}
export function isToolResultBlock(block: any): block is ToolResultBlock {
return block && typeof block === 'object' && block.type === 'tool_result';
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,14 +15,16 @@ import {
FatalInputError,
promptIdContext,
OutputFormat,
JsonFormatter,
uiTelemetryService,
} from '@qwen-code/qwen-code-core';
import type { Content, Part } from '@google/genai';
import type { Content, Part, PartListUnion } from '@google/genai';
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js';
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
import type { ControlService } from './nonInteractive/control/ControlService.js';
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
import {
handleError,
@@ -30,73 +32,144 @@ import {
handleCancellationError,
handleMaxTurnsExceededError,
} from './utils/errors.js';
import {
normalizePartList,
extractPartsFromUserMessage,
buildSystemMessage,
createTaskToolProgressHandler,
computeUsageFromMetrics,
} from './utils/nonInteractiveHelpers.js';
/**
* Provides optional overrides for `runNonInteractive` execution.
*
* @param abortController - Optional abort controller for cancellation.
* @param adapter - Optional JSON output adapter for structured output formats.
* @param userMessage - Optional CLI user message payload for preformatted input.
* @param controlService - Optional control service for future permission handling.
*/
export interface RunNonInteractiveOptions {
abortController?: AbortController;
adapter?: JsonOutputAdapterInterface;
userMessage?: CLIUserMessage;
controlService?: ControlService;
}
/**
* Executes the non-interactive CLI flow for a single request.
*/
export async function runNonInteractive(
config: Config,
settings: LoadedSettings,
input: string,
prompt_id: string,
options: RunNonInteractiveOptions = {},
): Promise<void> {
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: config.getDebugMode(),
});
// Create output adapter based on format
let adapter: JsonOutputAdapterInterface | undefined;
const outputFormat = config.getOutputFormat();
if (options.adapter) {
adapter = options.adapter;
} else if (outputFormat === OutputFormat.JSON) {
adapter = new JsonOutputAdapter(config);
} else if (outputFormat === OutputFormat.STREAM_JSON) {
adapter = new StreamJsonOutputAdapter(
config,
config.getIncludePartialMessages(),
);
}
// Get readonly values once at the start
const sessionId = config.getSessionId();
const permissionMode = config.getApprovalMode() as PermissionMode;
let turnCount = 0;
let totalApiDurationMs = 0;
const startTime = Date.now();
const stdoutErrorHandler = (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
process.stdout.removeListener('error', stdoutErrorHandler);
process.exit(0);
}
};
const geminiClient = config.getGeminiClient();
const abortController = options.abortController ?? new AbortController();
// Setup signal handlers for graceful shutdown
const shutdownHandler = () => {
if (config.getDebugMode()) {
console.error('[runNonInteractive] Shutdown signal received');
}
abortController.abort();
};
try {
consolePatcher.patch();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// Exit gracefully if the pipe is closed.
process.exit(0);
}
});
process.stdout.on('error', stdoutErrorHandler);
const geminiClient = config.getGeminiClient();
process.on('SIGINT', shutdownHandler);
process.on('SIGTERM', shutdownHandler);
const abortController = new AbortController();
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
options.userMessage,
);
let query: Part[] | undefined;
if (isSlashCommand(input)) {
const slashCommandResult = await handleSlashCommand(
input,
abortController,
config,
settings,
);
// If a slash command is found and returns a prompt, use it.
// Otherwise, slashCommandResult fall through to the default prompt
// handling.
if (slashCommandResult) {
query = slashCommandResult as Part[];
}
}
if (!query) {
const { processedQuery, shouldProceed } = await handleAtCommand({
query: input,
config,
addItem: (_item, _timestamp) => 0,
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
});
if (!shouldProceed || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
throw new FatalInputError(
'Exiting due to an error processing the @ command.',
if (!initialPartList) {
let slashHandled = false;
if (isSlashCommand(input)) {
const slashCommandResult = await handleSlashCommand(
input,
abortController,
config,
settings,
);
if (slashCommandResult) {
// A slash command can replace the prompt entirely; fall back to @-command processing otherwise.
initialPartList = slashCommandResult as PartListUnion;
slashHandled = true;
}
}
if (!slashHandled) {
const { processedQuery, shouldProceed } = await handleAtCommand({
query: input,
config,
addItem: (_item, _timestamp) => 0,
onDebugMessage: () => {},
messageId: Date.now(),
signal: abortController.signal,
});
if (!shouldProceed || !processedQuery) {
// An error occurred during @include processing (e.g., file not found).
// The error message is already logged by handleAtCommand.
throw new FatalInputError(
'Exiting due to an error processing the @ command.',
);
}
initialPartList = processedQuery as PartListUnion;
}
query = processedQuery as Part[];
}
let currentMessages: Content[] = [{ role: 'user', parts: query }];
if (!initialPartList) {
initialPartList = [{ text: input }];
}
const initialParts = normalizePartList(initialPartList);
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
let turnCount = 0;
while (true) {
turnCount++;
if (
@@ -105,43 +178,124 @@ export async function runNonInteractive(
) {
handleMaxTurnsExceededError(config);
}
const toolCallRequests: ToolCallRequestInfo[] = [];
const toolCallRequests: ToolCallRequestInfo[] = [];
const apiStartTime = Date.now();
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
);
let responseText = '';
// Start assistant message for this turn
if (adapter) {
adapter.startAssistantMessage();
}
for await (const event of responseStream) {
if (abortController.signal.aborted) {
handleCancellationError(config);
}
if (event.type === GeminiEventType.Content) {
if (config.getOutputFormat() === OutputFormat.JSON) {
responseText += event.value;
} else {
process.stdout.write(event.value);
if (adapter) {
// Use adapter for all event processing
adapter.processEvent(event);
if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
}
} else {
// Text output mode - direct stdout
if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
}
} else if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
}
}
// Finalize assistant message
if (adapter) {
adapter.finalizeAssistantMessage();
}
totalApiDurationMs += Date.now() - apiStartTime;
if (toolCallRequests.length > 0) {
const toolResponseParts: Part[] = [];
for (const requestInfo of toolCallRequests) {
const finalRequestInfo = requestInfo;
/*
if (options.controlService) {
const permissionResult =
await options.controlService.permission.shouldAllowTool(
requestInfo,
);
if (!permissionResult.allowed) {
if (config.getDebugMode()) {
console.error(
`[runNonInteractive] Tool execution denied: ${requestInfo.name}`,
permissionResult.message ?? '',
);
}
if (adapter && permissionResult.message) {
adapter.emitSystemMessage('tool_denied', {
tool: requestInfo.name,
message: permissionResult.message,
});
}
continue;
}
if (permissionResult.updatedArgs) {
finalRequestInfo = {
...requestInfo,
args: permissionResult.updatedArgs,
};
}
}
const toolCallUpdateCallback = options.controlService
? options.controlService.permission.getToolCallUpdateCallback()
: undefined;
*/
// Only pass outputUpdateHandler for Task tool
const isTaskTool = finalRequestInfo.name === 'task';
const taskToolProgress = isTaskTool
? createTaskToolProgressHandler(
config,
finalRequestInfo.callId,
adapter,
)
: undefined;
const taskToolProgressHandler = taskToolProgress?.handler;
const toolResponse = await executeToolCall(
config,
requestInfo,
finalRequestInfo,
abortController.signal,
isTaskTool && taskToolProgressHandler
? {
outputUpdateHandler: taskToolProgressHandler,
/*
toolCallUpdateCallback
? { onToolCallsUpdate: toolCallUpdateCallback }
: undefined,
*/
}
: undefined,
);
// Note: In JSON mode, subagent messages are automatically added to the main
// adapter's messages array and will be output together on emitResult()
if (toolResponse.error) {
// In JSON/STREAM_JSON mode, tool errors are tolerated and formatted
// as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode
// from config and allow the session to continue so the LLM can decide what to do next.
// In text mode, we still log the error.
handleToolError(
requestInfo.name,
finalRequestInfo.name,
toolResponse.error,
config,
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
@@ -149,6 +303,13 @@ export async function runNonInteractive(
? toolResponse.resultDisplay
: undefined,
);
// Note: We no longer emit a separate system message for tool errors
// in JSON/STREAM_JSON mode, as the error is already captured in the
// tool_result block with is_error=true.
}
if (adapter) {
adapter.emitToolResult(finalRequestInfo, toolResponse);
}
if (toolResponse.responseParts) {
@@ -157,20 +318,57 @@ export async function runNonInteractive(
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
process.stdout.write(formatter.format(responseText, stats));
// For JSON and STREAM_JSON modes, compute usage from metrics
if (adapter) {
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError: false,
durationMs: Date.now() - startTime,
apiDurationMs: totalApiDurationMs,
numTurns: turnCount,
usage,
stats,
});
} else {
process.stdout.write('\n'); // Ensure a final newline
// Text output mode - no usage needed
process.stdout.write('\n');
}
return;
}
}
} catch (error) {
// For JSON and STREAM_JSON modes, compute usage from metrics
const message = error instanceof Error ? error.message : String(error);
if (adapter) {
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError: true,
durationMs: Date.now() - startTime,
apiDurationMs: totalApiDurationMs,
numTurns: turnCount,
errorMessage: message,
usage,
stats,
});
}
handleError(error, config);
} finally {
consolePatcher.cleanup();
process.stdout.removeListener('error', stdoutErrorHandler);
// Cleanup signal handlers
process.removeListener('SIGINT', shutdownHandler);
process.removeListener('SIGTERM', shutdownHandler);
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry(config);
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -10,6 +10,9 @@ import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import * as auth from './config/auth.js';
import { type LoadedSettings } from './config/settings.js';
import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.js';
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
import * as cleanupModule from './utils/cleanup.js';
describe('validateNonInterActiveAuth', () => {
let originalEnvGeminiApiKey: string | undefined;
@@ -17,8 +20,8 @@ describe('validateNonInterActiveAuth', () => {
let originalEnvGcp: string | undefined;
let originalEnvOpenAiApiKey: string | undefined;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let processExitSpy: ReturnType<typeof vi.spyOn>;
let refreshAuthMock: vi.Mock;
let processExitSpy: ReturnType<typeof vi.spyOn<[code?: number], never>>;
let refreshAuthMock: ReturnType<typeof vi.fn>;
let mockSettings: LoadedSettings;
beforeEach(() => {
@@ -33,7 +36,7 @@ describe('validateNonInterActiveAuth', () => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code}) called`);
});
}) as ReturnType<typeof vi.spyOn<[code?: number], never>>;
refreshAuthMock = vi.fn().mockResolvedValue('refreshed');
mockSettings = {
system: { path: '', settings: {} },
@@ -235,7 +238,24 @@ describe('validateNonInterActiveAuth', () => {
});
describe('JSON output mode', () => {
it('prints JSON error when no auth is configured and exits with code 1', async () => {
let emitResultMock: ReturnType<typeof vi.fn>;
let runExitCleanupMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
emitResultMock = vi.fn();
runExitCleanupMock = vi.fn().mockResolvedValue(undefined);
vi.spyOn(JsonOutputAdapterModule, 'JsonOutputAdapter').mockImplementation(
() =>
({
emitResult: emitResultMock,
}) as unknown as JsonOutputAdapterModule.JsonOutputAdapter,
);
vi.spyOn(cleanupModule, 'runExitCleanup').mockImplementation(
runExitCleanupMock,
);
});
it('emits error result and exits when no auth is configured', async () => {
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
@@ -244,7 +264,6 @@ describe('validateNonInterActiveAuth', () => {
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
let thrown: Error | undefined;
try {
await validateNonInteractiveAuth(
undefined,
@@ -252,21 +271,27 @@ describe('validateNonInterActiveAuth', () => {
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
thrown = e as Error;
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(thrown?.message).toBe('process.exit(1) called');
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
const payload = JSON.parse(errorArg);
expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain(
'Please set an Auth method in your',
);
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: expect.stringContaining(
'Please set an Auth method in your',
),
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => {
it('emits error result and exits when enforced auth mismatches current auth', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key';
@@ -278,7 +303,6 @@ describe('validateNonInterActiveAuth', () => {
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
let thrown: Error | undefined;
try {
await validateNonInteractiveAuth(
undefined,
@@ -286,23 +310,27 @@ describe('validateNonInterActiveAuth', () => {
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
thrown = e as Error;
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(thrown?.message).toBe('process.exit(1) called');
{
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
const payload = JSON.parse(errorArg);
expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1);
expect(payload.error.message).toContain(
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: expect.stringContaining(
'The configured auth type is qwen-oauth, but the current auth type is openai.',
);
}
),
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => {
it('emits error result and exits when validateAuthMethod fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key';
@@ -314,7 +342,6 @@ describe('validateNonInterActiveAuth', () => {
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
let thrown: Error | undefined;
try {
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
@@ -322,18 +349,159 @@ describe('validateNonInterActiveAuth', () => {
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
thrown = e as Error;
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(thrown?.message).toBe('process.exit(1) called');
{
const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string;
const payload = JSON.parse(errorArg);
expect(payload.error.type).toBe('Error');
expect(payload.error.code).toBe(1);
expect(payload.error.message).toBe('Auth error!');
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: 'Auth error!',
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
describe('STREAM_JSON output mode', () => {
let emitResultMock: ReturnType<typeof vi.fn>;
let runExitCleanupMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
emitResultMock = vi.fn();
runExitCleanupMock = vi.fn().mockResolvedValue(undefined);
vi.spyOn(
StreamJsonOutputAdapterModule,
'StreamJsonOutputAdapter',
).mockImplementation(
() =>
({
emitResult: emitResultMock,
}) as unknown as StreamJsonOutputAdapterModule.StreamJsonOutputAdapter,
);
vi.spyOn(cleanupModule, 'runExitCleanup').mockImplementation(
runExitCleanupMock,
);
});
it('emits error result and exits when no auth is configured', async () => {
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: expect.stringContaining(
'Please set an Auth method in your',
),
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('emits error result and exits when enforced auth mismatches current auth', async () => {
mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH;
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
try {
await validateNonInteractiveAuth(
undefined,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: expect.stringContaining(
'The configured auth type is qwen-oauth, but the current auth type is openai.',
),
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('emits error result and exits when validateAuthMethod fails', async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ authType: undefined }),
} as unknown as Config;
try {
await validateNonInteractiveAuth(
AuthType.USE_OPENAI,
undefined,
nonInteractiveConfig,
mockSettings,
);
expect.fail('Should have exited');
} catch (e) {
expect((e as Error).message).toContain('process.exit(1) called');
}
expect(emitResultMock).toHaveBeenCalledWith({
isError: true,
errorMessage: 'Auth error!',
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
expect(runExitCleanupMock).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -9,7 +9,9 @@ import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core';
import { USER_SETTINGS_PATH } from './config/settings.js';
import { validateAuthMethod } from './config/auth.js';
import { type LoadedSettings } from './config/settings.js';
import { handleError } from './utils/errors.js';
import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js';
import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js';
import { runExitCleanup } from './utils/cleanup.js';
function getAuthTypeFromEnv(): AuthType | undefined {
if (process.env['OPENAI_API_KEY']) {
@@ -27,7 +29,7 @@ export async function validateNonInteractiveAuth(
useExternalAuth: boolean | undefined,
nonInteractiveConfig: Config,
settings: LoadedSettings,
) {
): Promise<Config> {
try {
const enforcedType = settings.merged.security?.auth?.enforcedType;
if (enforcedType) {
@@ -58,15 +60,38 @@ export async function validateNonInteractiveAuth(
await nonInteractiveConfig.refreshAuth(authType);
return nonInteractiveConfig;
} catch (error) {
if (nonInteractiveConfig.getOutputFormat() === OutputFormat.JSON) {
handleError(
error instanceof Error ? error : new Error(String(error)),
nonInteractiveConfig,
1,
);
} else {
console.error(error instanceof Error ? error.message : String(error));
const outputFormat = nonInteractiveConfig.getOutputFormat();
// In JSON and STREAM_JSON modes, emit error result and exit
if (
outputFormat === OutputFormat.JSON ||
outputFormat === OutputFormat.STREAM_JSON
) {
let adapter;
if (outputFormat === OutputFormat.JSON) {
adapter = new JsonOutputAdapter(nonInteractiveConfig);
} else {
adapter = new StreamJsonOutputAdapter(
nonInteractiveConfig,
nonInteractiveConfig.getIncludePartialMessages(),
);
}
const errorMessage =
error instanceof Error ? error.message : String(error);
adapter.emitResult({
isError: true,
errorMessage,
durationMs: 0,
apiDurationMs: 0,
numTurns: 0,
usage: undefined,
});
await runExitCleanup();
process.exit(1);
}
// For other modes (text), use existing error handling
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}