Merge branch 'feature/stream-json-migration' into mingholy/feat/cli-sdk

This commit is contained in:
mingholy.lmh
2025-11-18 14:38:29 +08:00
14 changed files with 611 additions and 172 deletions

View File

@@ -484,6 +484,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

@@ -357,12 +357,9 @@ describe('gemini.tsx main function', () => {
}
expect(runStreamJsonSpy).toHaveBeenCalledTimes(1);
const [configArg, settingsArg, promptArg] = runStreamJsonSpy.mock.calls[0];
const [configArg, inputArg] = runStreamJsonSpy.mock.calls[0];
expect(configArg).toBe(validatedConfig);
expect(settingsArg).toMatchObject({
merged: expect.objectContaining({ security: expect.any(Object) }),
});
expect(promptArg).toBe('hello stream');
expect(inputArg).toBe('hello stream');
expect(validateAuthSpy).toHaveBeenCalledWith(
undefined,

View File

@@ -220,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),
@@ -350,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) {
@@ -443,9 +446,7 @@ export async function main() {
await runNonInteractiveStreamJson(
nonInteractiveConfig,
settings,
trimmedInput.length > 0 ? trimmedInput : '',
prompt_id,
);
await runExitCleanup();
process.exit(0);

View File

@@ -6,7 +6,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
import { runNonInteractiveStreamJson } from './session.js';
import type {
CLIUserMessage,
@@ -74,14 +73,6 @@ function createConfig(overrides: ConfigOverrides = {}): Config {
return { ...base, ...overrides } as unknown as Config;
}
function createSettings(): LoadedSettings {
return {
merged: {
security: { auth: {} },
},
} as unknown as LoadedSettings;
}
function createUserMessage(content: string): CLIUserMessage {
return {
type: 'user',
@@ -145,7 +136,6 @@ function createControlCancel(requestId: string): ControlCancelRequest {
describe('runNonInteractiveStreamJson', () => {
let config: Config;
let settings: LoadedSettings;
let mockInputReader: {
read: () => AsyncGenerator<
| CLIUserMessage
@@ -170,7 +160,6 @@ describe('runNonInteractiveStreamJson', () => {
beforeEach(() => {
config = createConfig();
settings = createSettings();
runNonInteractiveMock.mockReset();
// Setup mocks
@@ -232,7 +221,7 @@ describe('runNonInteractiveStreamJson', () => {
yield initRequest;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
@@ -246,7 +235,7 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
const runCall = runNonInteractiveMock.mock.calls[0];
@@ -272,7 +261,7 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage2;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
});
@@ -293,7 +282,7 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage2;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
// Both messages should be processed
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
@@ -308,7 +297,7 @@ describe('runNonInteractiveStreamJson', () => {
yield controlRequest;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(2);
expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(1, initRequest);
@@ -324,7 +313,7 @@ describe('runNonInteractiveStreamJson', () => {
yield controlResponse;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
controlResponse,
@@ -340,7 +329,7 @@ describe('runNonInteractiveStreamJson', () => {
yield cancelRequest;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleCancel).toHaveBeenCalledWith('req-2');
});
@@ -360,7 +349,7 @@ describe('runNonInteractiveStreamJson', () => {
yield controlRequest;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(controlRequest);
});
@@ -380,7 +369,7 @@ describe('runNonInteractiveStreamJson', () => {
yield controlResponse;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith(
controlResponse,
@@ -394,12 +383,12 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
expect(runNonInteractiveMock).toHaveBeenCalledWith(
config,
settings,
expect.objectContaining({ merged: expect.any(Object) }),
'Test message',
expect.stringContaining('test-session'),
expect.objectContaining({
@@ -427,12 +416,12 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(1);
expect(runNonInteractiveMock).toHaveBeenCalledWith(
config,
settings,
expect.objectContaining({ merged: expect.any(Object) }),
'First part\nSecond part',
expect.stringContaining('test-session'),
expect.objectContaining({
@@ -457,7 +446,7 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).not.toHaveBeenCalled();
});
@@ -472,7 +461,7 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
// Error should be caught and handled gracefully
});
@@ -484,9 +473,9 @@ describe('runNonInteractiveStreamJson', () => {
throw streamError;
} as typeof mockInputReader.read;
await expect(
runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id'),
).rejects.toThrow('Stream error');
await expect(runNonInteractiveStreamJson(config, '')).rejects.toThrow(
'Stream error',
);
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
});
@@ -517,7 +506,7 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
// Verify initialization happened
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest);
@@ -536,7 +525,7 @@ describe('runNonInteractiveStreamJson', () => {
yield userMessage2;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(runNonInteractiveMock).toHaveBeenCalledTimes(2);
const promptId1 = runNonInteractiveMock.mock.calls[0][3] as string;
@@ -553,7 +542,7 @@ describe('runNonInteractiveStreamJson', () => {
yield controlRequest;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
// Should not transition to idle since it's not an initialize request
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
@@ -564,7 +553,7 @@ describe('runNonInteractiveStreamJson', () => {
// Empty stream - should complete immediately
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1);
expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1);
@@ -575,7 +564,7 @@ describe('runNonInteractiveStreamJson', () => {
// Empty stream
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
});
it('calls dispatcher shutdown on completion', async () => {
@@ -585,7 +574,7 @@ describe('runNonInteractiveStreamJson', () => {
yield initRequest;
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockDispatcher.shutdown).toHaveBeenCalledTimes(1);
});
@@ -595,7 +584,7 @@ describe('runNonInteractiveStreamJson', () => {
// Empty stream
};
await runNonInteractiveStreamJson(config, settings, '', 'test-prompt-id');
await runNonInteractiveStreamJson(config, '');
expect(mockConsolePatcher.cleanup).toHaveBeenCalled();
});

View File

@@ -16,7 +16,6 @@
*/
import type { Config } from '@qwen-code/qwen-code-core';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
import { StreamJsonInputReader } from './io/StreamJsonInputReader.js';
import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js';
import { ControlContext } from './control/ControlContext.js';
@@ -39,8 +38,9 @@ import {
isControlResponse,
isControlCancel,
} from './types.js';
import type { LoadedSettings } from '../config/settings.js';
import { createMinimalSettings } from '../config/settings.js';
import { runNonInteractive } from '../nonInteractiveCli.js';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
const SESSION_STATE = {
INITIALIZING: 'initializing',
@@ -87,7 +87,6 @@ class SessionManager {
private userMessageQueue: CLIUserMessage[] = [];
private abortController: AbortController;
private config: Config;
private settings: LoadedSettings;
private sessionId: string;
private promptIdCounter: number = 0;
private inputReader: StreamJsonInputReader;
@@ -96,28 +95,17 @@ class SessionManager {
private dispatcher: ControlDispatcher | null = null;
private controlService: ControlService | null = null;
private controlSystemEnabled: boolean | null = null;
private consolePatcher: ConsolePatcher;
private debugMode: boolean;
private shutdownHandler: (() => void) | null = null;
private initialPrompt: CLIUserMessage | null = null;
constructor(
config: Config,
settings: LoadedSettings,
initialPrompt?: CLIUserMessage,
) {
constructor(config: Config, initialPrompt?: CLIUserMessage) {
this.config = config;
this.settings = settings;
this.sessionId = config.getSessionId();
this.debugMode = config.getDebugMode();
this.abortController = new AbortController();
this.initialPrompt = initialPrompt ?? null;
this.consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: this.debugMode,
});
this.inputReader = new StreamJsonInputReader();
this.outputAdapter = new StreamJsonOutputAdapter(
config,
@@ -232,8 +220,6 @@ class SessionManager {
*/
async run(): Promise<void> {
try {
this.consolePatcher.patch();
if (this.debugMode) {
console.error('[SessionManager] Starting session', this.sessionId);
}
@@ -264,7 +250,6 @@ class SessionManager {
await this.shutdown();
throw error;
} finally {
this.consolePatcher.cleanup();
// Ensure signal handlers are always cleaned up even if shutdown wasn't called
this.cleanupSignalHandlers();
}
@@ -578,11 +563,17 @@ class SessionManager {
const promptId = this.getNextPromptId();
try {
await runNonInteractive(this.config, this.settings, input, promptId, {
abortController: this.abortController,
adapter: this.outputAdapter,
controlService: this.controlService ?? undefined,
});
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) {
@@ -695,31 +686,36 @@ function extractUserMessageText(message: CLIUserMessage): string | null {
* Entry point for stream-json mode
*
* @param config - Configuration object
* @param settings - Loaded settings
* @param input - Optional initial prompt input to process before reading from stream
* @param promptId - Prompt ID (not used in stream-json mode but kept for API compatibility)
*/
export async function runNonInteractiveStreamJson(
config: Config,
settings: LoadedSettings,
input: string,
_promptId: string,
): Promise<void> {
// 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 consolePatcher = new ConsolePatcher({
debugMode: config.getDebugMode(),
});
consolePatcher.patch();
const manager = new SessionManager(config, settings, initialPrompt);
await manager.run();
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

@@ -25,7 +25,6 @@ import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAda
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,
@@ -67,11 +66,6 @@ export async function runNonInteractive(
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();
@@ -102,12 +96,22 @@ export async function runNonInteractive(
}
};
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();
process.stdout.on('error', stdoutErrorHandler);
const geminiClient = config.getGeminiClient();
const abortController = options.abortController ?? new AbortController();
process.on('SIGINT', shutdownHandler);
process.on('SIGTERM', shutdownHandler);
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
options.userMessage,
@@ -362,7 +366,9 @@ export async function runNonInteractive(
handleError(error, config);
} finally {
process.stdout.removeListener('error', stdoutErrorHandler);
consolePatcher.cleanup();
// Cleanup signal handlers
process.removeListener('SIGINT', shutdownHandler);
process.removeListener('SIGTERM', shutdownHandler);
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry(config);
}

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

View File

@@ -620,6 +620,17 @@ export class SubAgentScope {
success,
error: errorMessage,
responseParts: call.response.responseParts,
/**
* Tools like todoWrite will add some extra contents to the result,
* making it unable to deserialize the `responseParts` to a JSON object.
* While `resultDisplay` is normally a string, if not we stringify it,
* so that we can deserialize it to a JSON object when needed.
*/
resultDisplay: call.response.resultDisplay
? typeof call.response.resultDisplay === 'string'
? call.response.resultDisplay
: JSON.stringify(call.response.resultDisplay)
: undefined,
durationMs: duration,
timestamp: Date.now(),
} as SubAgentToolResultEvent);