Merge branch 'main' into feat/image-attachment

This commit is contained in:
LaZzyMan 2026-02-02 20:23:34 +08:00
commit c92e2b8351
301 changed files with 33924 additions and 5940 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.8.2",
"version": "0.9.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.9.0"
},
"dependencies": {
"@google/genai": "1.30.0",

View file

@ -15,10 +15,10 @@ import {
qwenOAuth2Events,
MCPServerConfig,
SessionService,
tokenLimit,
type Config,
type ConversationRecord,
type DeviceAuthorizationData,
tokenLimit,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
@ -290,7 +290,7 @@ class GeminiAgent {
}
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
const selectedType = config.getModelsConfig().getCurrentAuthType();
if (!selectedType) {
throw acp.RequestError.authRequired(
'Use Qwen Code CLI to authenticate first.',
@ -379,7 +379,7 @@ class GeminiAgent {
name: model.label,
description: model.description ?? null,
_meta: {
contextLimit: tokenLimit(model.id),
contextLimit: model.contextWindowSize ?? tokenLimit(model.id),
},
}));
@ -387,12 +387,15 @@ class GeminiAgent {
currentModelId &&
!mappedAvailableModels.some((model) => model.modelId === currentModelId)
) {
const currentContextWindowSize =
config.getContentGeneratorConfig()?.contextWindowSize ??
tokenLimit(currentModelId);
mappedAvailableModels.unshift({
modelId: currentModelId,
name: currentModelId,
description: null,
_meta: {
contextLimit: tokenLimit(currentModelId),
contextLimit: currentContextWindowSize,
},
});
}

View file

@ -367,6 +367,8 @@ export const sessionUpdateMetaSchema = z.object({
usage: usageSchema.optional().nullable(),
durationMs: z.number().optional().nullable(),
toolName: z.string().optional().nullable(),
parentToolCallId: z.string().optional().nullable(),
subagentType: z.string().optional().nullable(),
});
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;

View file

@ -474,8 +474,17 @@ export class Session implements SessionContext {
}
).eventEmitter;
// Extract subagent metadata from TaskTool call
const parentToolCallId = callId;
const subagentType = (args['subagent_type'] as string) ?? '';
// Create a SubAgentTracker for this tool execution
const subAgentTracker = new SubAgentTracker(this, this.client);
const subAgentTracker = new SubAgentTracker(
this,
this.client,
parentToolCallId,
subagentType,
);
// Set up sub-agent tool tracking
subAgentCleanupFunctions = subAgentTracker.setup(

View file

@ -14,6 +14,7 @@ import type {
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentStreamTextEvent,
ToolEditConfirmationDetails,
ToolInfoConfirmationDetails,
} from '@qwen-code/qwen-code-core';
@ -101,6 +102,18 @@ function createInfoConfirmation(
};
}
// Helper to create a mock SubAgentStreamTextEvent with required fields
function createStreamTextEvent(
overrides: Partial<SubAgentStreamTextEvent> & { text: string },
): SubAgentStreamTextEvent {
return {
subagentId: 'test-subagent',
round: 1,
timestamp: Date.now(),
...overrides,
};
}
describe('SubAgentTracker', () => {
let mockContext: SessionContext;
let mockClient: acp.Client;
@ -132,7 +145,12 @@ describe('SubAgentTracker', () => {
requestPermission: requestPermissionSpy,
} as unknown as acp.Client;
tracker = new SubAgentTracker(mockContext, mockClient);
tracker = new SubAgentTracker(
mockContext,
mockClient,
'parent-call-123',
'test-subagent',
);
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
abortController = new AbortController();
});
@ -162,6 +180,10 @@ describe('SubAgentTracker', () => {
SubAgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
it('should remove event listeners on cleanup', () => {
@ -182,6 +204,10 @@ describe('SubAgentTracker', () => {
SubAgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
});
@ -214,6 +240,11 @@ describe('SubAgentTracker', () => {
locations: [],
kind: 'other',
rawInput: { path: '/test.ts' },
_meta: expect.objectContaining({
toolName: 'read_file',
parentToolCallId: 'parent-call-123',
subagentType: 'test-subagent',
}),
}),
);
});
@ -283,6 +314,11 @@ describe('SubAgentTracker', () => {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'completed',
_meta: expect.objectContaining({
toolName: 'read_file',
parentToolCallId: 'parent-call-123',
subagentType: 'test-subagent',
}),
}),
);
});
@ -305,6 +341,11 @@ describe('SubAgentTracker', () => {
expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
_meta: expect.objectContaining({
toolName: 'read_file',
parentToolCallId: 'parent-call-123',
subagentType: 'test-subagent',
}),
}),
);
});
@ -522,4 +563,163 @@ describe('SubAgentTracker', () => {
);
});
});
describe('stream text handling', () => {
it('should emit agent_message_chunk on STREAM_TEXT event', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createStreamTextEvent({
text: 'Hello, this is a response from the model.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Hello, this is a response from the model.',
},
}),
);
});
it('should emit multiple chunks for multiple STREAM_TEXT events', async () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'First chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Second chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Third chunk' }),
);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledTimes(3);
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'First chunk ' },
}),
);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Second chunk ' },
}),
);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Third chunk' },
}),
);
});
it('should not emit when aborted', async () => {
tracker.setup(eventEmitter, abortController.signal);
abortController.abort();
const event = createStreamTextEvent({
text: 'This should not be emitted',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
it('should emit agent_thought_chunk when thought flag is true', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createStreamTextEvent({
text: 'Let me think about this...',
thought: true,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: 'Let me think about this...',
},
}),
);
});
it('should emit agent_message_chunk when thought flag is false', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createStreamTextEvent({
text: 'Here is the answer.',
thought: false,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Here is the answer.',
},
}),
);
});
it('should emit agent_message_chunk when thought flag is undefined', async () => {
tracker.setup(eventEmitter, abortController.signal);
// Event without thought flag (undefined)
const event = createStreamTextEvent({
text: 'Default behavior text.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Default behavior text.',
},
}),
);
});
});
});

View file

@ -10,6 +10,7 @@ import type {
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
SubAgentStreamTextEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
@ -77,11 +78,23 @@ export class SubAgentTracker {
constructor(
private readonly ctx: SessionContext,
private readonly client: acp.Client,
private readonly parentToolCallId: string,
private readonly subagentType: string,
) {
this.toolCallEmitter = new ToolCallEmitter(ctx);
this.messageEmitter = new MessageEmitter(ctx);
}
/**
* Gets the subagent metadata to attach to all events.
*/
private getSubagentMeta() {
return {
parentToolCallId: this.parentToolCallId,
subagentType: this.subagentType,
};
}
/**
* Sets up event listeners for a sub-agent's tool events.
*
@ -97,11 +110,13 @@ export class SubAgentTracker {
const onToolResult = this.createToolResultHandler(abortSignal);
const onApproval = this.createApprovalHandler(abortSignal);
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
const onStreamText = this.createStreamTextHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.on(SubAgentEventType.STREAM_TEXT, onStreamText);
return [
() => {
@ -109,6 +124,7 @@ export class SubAgentTracker {
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.off(SubAgentEventType.STREAM_TEXT, onStreamText);
// Clean up any remaining states
this.toolStates.clear();
},
@ -151,6 +167,7 @@ export class SubAgentTracker {
toolName: event.name,
callId: event.callId,
args: event.args,
subagentMeta: this.getSubagentMeta(),
});
};
}
@ -175,6 +192,7 @@ export class SubAgentTracker {
message: event.responseParts ?? [],
resultDisplay: event.resultDisplay,
args: state?.args,
subagentMeta: this.getSubagentMeta(),
});
// Clean up state
@ -269,7 +287,32 @@ export class SubAgentTracker {
const event = args[0] as SubAgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
this.messageEmitter.emitUsageMetadata(
event.usage,
'',
event.durationMs,
this.getSubagentMeta(),
);
};
}
/**
* Creates a handler for stream text events.
* Emits agent message or thought chunks for text content from subagent model responses.
*/
private createStreamTextHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentStreamTextEvent;
if (abortSignal.aborted) return;
// Emit streamed text as agent message or thought based on the flag
void this.messageEmitter.emitMessage(
event.text,
'assistant',
event.thought ?? false,
);
};
}

View file

@ -53,6 +53,7 @@ export class MessageEmitter extends BaseEmitter {
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
subagentMeta?: import('../types.js').SubagentMeta,
): Promise<void> {
const usage: Usage = {
promptTokens: usageMetadata.promptTokenCount,
@ -63,7 +64,9 @@ export class MessageEmitter extends BaseEmitter {
};
const meta =
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
typeof durationMs === 'number'
? { usage, durationMs, ...subagentMeta }
: { usage, ...subagentMeta };
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',

View file

@ -11,6 +11,7 @@ import type {
ToolCallStartParams,
ToolCallResultParams,
ResolvedToolMetadata,
SubagentMeta,
} from '../types.js';
import type * as acp from '../../acp.js';
import type { Part } from '@google/genai';
@ -65,7 +66,10 @@ export class ToolCallEmitter extends BaseEmitter {
locations,
kind,
rawInput: params.args ?? {},
_meta: { toolName: params.toolName },
_meta: {
toolName: params.toolName,
...params.subagentMeta,
},
});
return true;
@ -121,7 +125,10 @@ export class ToolCallEmitter extends BaseEmitter {
toolCallId: params.callId,
status: params.success ? 'completed' : 'failed',
content: contentArray,
_meta: { toolName: params.toolName },
_meta: {
toolName: params.toolName,
...params.subagentMeta,
},
};
// Add rawOutput from resultDisplay
@ -137,12 +144,15 @@ export class ToolCallEmitter extends BaseEmitter {
* Use this for explicit error handling when not using emitResult.
*
* @param callId - The tool call ID
* @param toolName - The tool name
* @param error - The error that occurred
* @param subagentMeta - Optional subagent metadata
*/
async emitError(
callId: string,
toolName: string,
error: Error,
subagentMeta?: SubagentMeta,
): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
@ -151,7 +161,10 @@ export class ToolCallEmitter extends BaseEmitter {
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
_meta: { toolName },
_meta: {
toolName,
...subagentMeta,
},
});
}

View file

@ -25,6 +25,16 @@ export interface SessionContext extends SessionUpdateSender {
readonly config: Config;
}
/**
* Subagent metadata for tracking parent tool call context.
*/
export interface SubagentMeta {
/** ID of the parent TaskTool call that created this subagent */
parentToolCallId?: string;
/** Type of subagent (from TaskParams.subagent_type) */
subagentType?: string;
}
/**
* Parameters for emitting a tool call start event.
*/
@ -37,6 +47,8 @@ export interface ToolCallStartParams {
args?: Record<string, unknown>;
/** Status of the tool call */
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
/** Optional subagent metadata */
subagentMeta?: SubagentMeta;
}
/**
@ -57,6 +69,8 @@ export interface ToolCallResultParams {
error?: Error;
/** Original args (fallback for TodoWriteTool todos extraction) */
args?: Record<string, unknown>;
/** Optional subagent metadata */
subagentMeta?: SubagentMeta;
}
/**

View file

@ -168,7 +168,7 @@ describe('validateAuthMethod', () => {
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should use config.modelsConfig.getModel() when Config is provided', () => {
it('should use config.getModelsConfig().getModel() when Config is provided', () => {
// Settings has a different model
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
@ -184,18 +184,18 @@ describe('validateAuthMethod', () => {
// Mock Config object that returns a different model (e.g., from CLI args)
const mockConfig = {
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('cli-model'),
},
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Set the env key for the CLI model, not the settings model
process.env['CLI_API_KEY'] = 'cli-key';
// Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model'
// Should use 'cli-model' from config.getModelsConfig().getModel(), not 'settings-model'
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
expect(result).toBeNull();
expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled();
expect(mockConfig.getModelsConfig).toHaveBeenCalled();
});
it('should fail validation when Config provides different model without matching env key', () => {
@ -217,9 +217,9 @@ describe('validateAuthMethod', () => {
} as unknown as ReturnType<typeof settings.loadSettings>);
const mockConfig = {
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('cli-model'),
},
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Don't set CLI_API_KEY - validation should fail

View file

@ -60,9 +60,9 @@ function hasApiKeyForAuth(
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID resolution
// Use config.getModelsConfig().getModel() if available for accurate model ID resolution
// that accounts for CLI args, env vars, and settings. Fall back to settings.model.name.
const modelId = config?.modelsConfig.getModel() ?? settings.model?.name;
const modelId = config?.getModelsConfig().getModel() ?? settings.model?.name;
// Try to find model-specific envKey from modelProviders
const modelConfig = findModelConfig(modelProviders, authType, modelId);
@ -184,9 +184,9 @@ export function validateAuthMethod(
const modelProviders = settings.merged.modelProviders as
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID
// Use config.getModelsConfig().getModel() if available for accurate model ID
const modelId =
config?.modelsConfig.getModel() ?? settings.merged.model?.name;
config?.getModelsConfig().getModel() ?? settings.merged.model?.name;
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
if (modelConfig && !modelConfig.baseUrl) {

View file

@ -13,18 +13,48 @@ import {
WriteFileTool,
DEFAULT_QWEN_MODEL,
OutputFormat,
NativeLspService,
} from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import type { Settings } from './settings.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
const createNativeLspServiceInstance = () => ({
discoverAndPrepare: vi.fn(),
start: vi.fn(),
definitions: vi.fn().mockResolvedValue([]),
references: vi.fn().mockResolvedValue([]),
workspaceSymbols: vi.fn().mockResolvedValue([]),
hover: vi.fn().mockResolvedValue(null),
documentSymbols: vi.fn().mockResolvedValue([]),
implementations: vi.fn().mockResolvedValue([]),
prepareCallHierarchy: vi.fn().mockResolvedValue([]),
incomingCalls: vi.fn().mockResolvedValue([]),
outgoingCalls: vi.fn().mockResolvedValue([]),
diagnostics: vi.fn().mockResolvedValue([]),
workspaceDiagnostics: vi.fn().mockResolvedValue([]),
codeActions: vi.fn().mockResolvedValue([]),
applyWorkspaceEdit: vi.fn().mockResolvedValue(false),
});
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
.fn()
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
}));
const nativeLspServiceMock = vi.mocked(NativeLspService);
const getLastLspInstance = () => {
const results = nativeLspServiceMock.mock.results;
if (results.length === 0) {
return undefined;
}
return results[results.length - 1]?.value as ReturnType<
typeof createNativeLspServiceInstance
>;
};
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof import('fs')>();
const pathMod = await import('node:path');
@ -79,6 +109,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualServer = await importOriginal<typeof ServerConfig>();
return {
...actualServer,
NativeLspService: vi
.fn()
.mockImplementation(() => createNativeLspServiceInstance()),
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getConnectionStatus: vi.fn(),
@ -514,6 +547,10 @@ describe('loadCliConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
nativeLspServiceMock.mockReset();
nativeLspServiceMock.mockImplementation(
() => createNativeLspServiceInstance() as unknown as NativeLspService,
);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
@ -543,6 +580,22 @@ describe('loadCliConfig', () => {
expect(config.getIncludePartialMessages()).toBe(true);
});
it('should initialize native LSP service when enabled', async () => {
process.argv = ['node', 'script.js', '--experimental-lsp'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(settings, argv);
// LSP is enabled via --experimental-lsp flag
expect(config.isLspEnabled()).toBe(true);
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
const lspInstance = getLastLspInstance();
expect(lspInstance).toBeDefined();
expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1);
expect(lspInstance?.start).toHaveBeenCalledTimes(1);
});
describe('Proxy configuration', () => {
const originalProxyEnv: { [key: string]: string | undefined } = {};
const proxyEnvVars = [

View file

@ -20,11 +20,15 @@ import {
OutputFormat,
isToolEnabled,
SessionService,
ideContextStore,
type ResumedSessionData,
type LspClient,
type ToolName,
EditTool,
ShellTool,
WriteFileTool,
NativeLspClient,
NativeLspService,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
@ -113,6 +117,7 @@ export interface CliArgs {
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined;
experimentalLsp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
openaiLogging: boolean | undefined;
@ -331,6 +336,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
return settings.experimental?.skills ?? legacySkills ?? false;
})(),
})
.option('experimental-lsp', {
type: 'boolean',
description:
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
default: false,
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
@ -713,6 +724,9 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// LSP configuration: enabled only via --experimental-lsp flag
const lspEnabled = argv.experimentalLsp === true;
let lspClient: LspClient | undefined;
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
@ -924,7 +938,7 @@ export async function loadCliConfig(
const modelProvidersConfig = settings.modelProviders;
return new Config({
const config = new Config({
sessionId,
sessionData,
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
@ -1016,7 +1030,34 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
lsp: {
enabled: lspEnabled,
},
});
if (lspEnabled) {
try {
const lspService = new NativeLspService(
config,
config.getWorkspaceContext(),
appEvents,
fileService,
ideContextStore,
{
requireTrustedWorkspace: folderTrust,
},
);
await lspService.discoverAndPrepare();
await lspService.start();
lspClient = new NativeLspClient(lspService);
config.setLspClient(lspClient);
} catch (err) {
logger.warn('Failed to initialize native LSP service:', err);
}
}
return config;
}
function mergeExcludeTools(

View file

@ -70,7 +70,6 @@ export interface SettingDefinition {
default: SettingsValue;
description?: string;
parentKey?: string;
childKey?: string;
key?: string;
properties?: SettingsSchema;
showInDialog?: boolean;
@ -598,7 +597,6 @@ const SETTINGS_SCHEMA = {
default: undefined as number | undefined,
description: 'Request timeout in milliseconds.',
parentKey: 'generationConfig',
childKey: 'timeout',
showInDialog: false,
},
maxRetries: {
@ -609,7 +607,6 @@ const SETTINGS_SCHEMA = {
default: undefined as number | undefined,
description: 'Maximum number of retries for failed requests.',
parentKey: 'generationConfig',
childKey: 'maxRetries',
showInDialog: false,
},
disableCacheControl: {
@ -620,7 +617,6 @@ const SETTINGS_SCHEMA = {
default: false,
description: 'Disable cache control for DashScope providers.',
parentKey: 'generationConfig',
childKey: 'disableCacheControl',
showInDialog: false,
},
schemaCompliance: {
@ -632,13 +628,23 @@ const SETTINGS_SCHEMA = {
description:
'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).',
parentKey: 'generationConfig',
childKey: 'schemaCompliance',
showInDialog: false,
options: [
{ value: 'auto', label: 'Auto (Default)' },
{ value: 'openapi_30', label: 'OpenAPI 3.0 Strict' },
],
},
contextWindowSize: {
type: 'number',
label: 'Context Window Size',
category: 'Generation Configuration',
requiresRestart: false,
default: undefined,
description:
"Overrides the default context window size for the selected model. Use this setting when a provider's effective context limit differs from Qwen Code's default. This value defines the model's assumed maximum context capacity, not a per-request token limit.",
parentKey: 'generationConfig',
showInDialog: false,
},
},
},
},

View file

@ -15,7 +15,6 @@ import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import { initializeI18n, type SupportedLanguage } from '../i18n/index.js';
import { initializeLlmOutputLanguage } from '../utils/languageUtils.js';
export interface InitializationResult {
authError: string | null;
@ -42,12 +41,9 @@ export async function initializeApp(
'auto';
await initializeI18n(languageSetting as SupportedLanguage | 'auto');
// Auto-detect and set LLM output language on first use
initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);
// Use authType from modelsConfig which respects CLI --auth-type argument
// over settings.security.auth.selectedType
const authType = config.modelsConfig.getCurrentAuthType();
const authType = config.getModelsConfig().getCurrentAuthType();
const authError = await performInitialAuth(config, authType);
// Fallback to user select when initial authentication fails
@ -61,7 +57,7 @@ export async function initializeApp(
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
!config.getModelsConfig().wasAuthTypeExplicitlyProvided() || !!authError;
if (config.getIdeMode()) {
const ideClient = await IdeClient.getInstance();

View file

@ -488,6 +488,7 @@ describe('gemini.tsx main function kitty protocol', () => {
excludeTools: undefined,
authType: undefined,
maxSessionTurns: undefined,
experimentalLsp: undefined,
channel: undefined,
chatRecording: undefined,
});

View file

@ -53,6 +53,7 @@ import { getCliVersion } from './utils/version.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
import { initializeLlmOutputLanguage } from './utils/languageUtils.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@ -252,7 +253,7 @@ export async function main() {
if (!settings.merged.security?.auth?.useExternal) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try {
const authType = partialConfig.modelsConfig.getCurrentAuthType();
const authType = partialConfig.getModelsConfig().getCurrentAuthType();
// Fresh users may not have selected/persisted an authType yet.
// In that case, defer auth prompting/selection to the main interactive flow.
if (authType) {
@ -327,6 +328,10 @@ export async function main() {
// We are now past the logic handling potentially launching a child process
// to run Gemini CLI. It is now safe to perform expensive initialization that
// may have side effects.
// Initialize output language file before config loads to ensure it's included in context
initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);
{
const config = await loadCliConfig(
settings.merged,

View file

@ -9,7 +9,7 @@ import type {
Config,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { GeminiEventType } from '@qwen-code/qwen-code-core';
import { GeminiEventType, OutputFormat } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import { JsonOutputAdapter } from './JsonOutputAdapter.js';
@ -17,6 +17,7 @@ function createMockConfig(): Config {
return {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getModel: vi.fn().mockReturnValue('test-model'),
getOutputFormat: vi.fn().mockReturnValue('json'),
} as unknown as Config;
}
@ -415,6 +416,79 @@ describe('JsonOutputAdapter', () => {
expect(resultMessage.num_turns).toBe(1);
});
it('should emit success result as text to stdout in text mode', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Response text');
});
it('should emit error result to stderr in text mode', () => {
const stderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT);
adapter.emitResult({
isError: true,
errorMessage: 'Test error message',
durationMs: 500,
apiDurationMs: 300,
numTurns: 1,
});
expect(stderrWriteSpy).toHaveBeenCalled();
const output = stderrWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Test error message');
stderrWriteSpy.mockRestore();
});
it('should use custom summary in text mode', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT);
adapter.emitResult({
isError: false,
summary: 'Custom summary text',
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Custom summary text');
});
it('should handle empty error message in text mode', () => {
const stderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT);
adapter.emitResult({
isError: true,
durationMs: 500,
apiDurationMs: 300,
numTurns: 1,
});
expect(stderrWriteSpy).toHaveBeenCalled();
const output = stderrWriteSpy.mock.calls[0][0] as string;
// When no errorMessage is provided, the default 'Unknown error' is used
expect(output).toBe('Unknown error');
stderrWriteSpy.mockRestore();
});
it('should emit error result', () => {
adapter.emitResult({
isError: true,

View file

@ -67,9 +67,17 @@ export class JsonOutputAdapter
);
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`);
if (this.config.getOutputFormat() === 'text') {
if (resultMessage.is_error) {
process.stderr.write(`${resultMessage.error?.message || ''}`);
} else {
process.stdout.write(`${resultMessage.result}`);
}
} else {
// 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 {

View file

@ -228,6 +228,7 @@ describe('runNonInteractive', () => {
}
it('should process input and write text output', async () => {
setupMetricsMock();
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello' },
{ type: GeminiEventType.Content, value: ' World' },
@ -253,13 +254,12 @@ describe('runNonInteractive', () => {
'prompt-id-1',
{ isContinuation: false },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
it('should handle a single tool call and respond', async () => {
setupMetricsMock();
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
@ -298,9 +298,7 @@ describe('runNonInteractive', () => {
mockConfig,
expect.objectContaining({ name: 'testTool' }),
expect.any(AbortSignal),
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
undefined,
);
// Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
@ -319,10 +317,10 @@ describe('runNonInteractive', () => {
{ isContinuation: true },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
});
it('should handle error during tool execution and should send error back to the model', async () => {
setupMetricsMock();
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
@ -397,6 +395,7 @@ describe('runNonInteractive', () => {
});
it('should exit with error if sendMessageStream throws initially', async () => {
setupMetricsMock();
const apiError = new Error('API connection failed');
mockGeminiClient.sendMessageStream.mockImplementation(() => {
throw apiError;
@ -413,6 +412,7 @@ describe('runNonInteractive', () => {
});
it('should not exit if a tool is not found, and should send error back to model', async () => {
setupMetricsMock();
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
@ -464,6 +464,7 @@ describe('runNonInteractive', () => {
});
it('should exit when max session turns are exceeded', async () => {
setupMetricsMock();
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
await expect(
runNonInteractive(
@ -476,6 +477,7 @@ describe('runNonInteractive', () => {
});
it('should preprocess @include commands before sending to the model', async () => {
setupMetricsMock();
// 1. Mock the imported atCommandProcessor
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
@ -866,6 +868,7 @@ describe('runNonInteractive', () => {
});
it('should execute a slash command that returns a prompt', async () => {
setupMetricsMock();
const mockCommand = {
name: 'testcommand',
description: 'a test command',
@ -907,6 +910,7 @@ describe('runNonInteractive', () => {
});
it('should handle command that requires confirmation by returning early', async () => {
setupMetricsMock();
const mockCommand = {
name: 'confirm',
description: 'a command that needs confirmation',
@ -925,13 +929,14 @@ describe('runNonInteractive', () => {
'prompt-id-confirm',
);
// Should write error message to stderr
// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
);
});
it('should treat an unknown slash command as a regular prompt', async () => {
setupMetricsMock();
// No commands are mocked, so any slash command is "unknown"
mockGetCommands.mockReturnValue([]);
@ -965,6 +970,7 @@ describe('runNonInteractive', () => {
});
it('should handle known but unsupported slash commands like /help by returning early', async () => {
setupMetricsMock();
// Mock a built-in command that exists but is not in the allowed list
const mockHelpCommand = {
name: 'help',
@ -981,13 +987,14 @@ describe('runNonInteractive', () => {
'prompt-id-help',
);
// Should write error message to stderr
// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.\n',
'The command "/help" is not supported in non-interactive mode.',
);
});
it('should handle unhandled command result types by returning early with error', async () => {
setupMetricsMock();
const mockCommand = {
name: 'noaction',
description: 'unhandled type',
@ -1007,11 +1014,12 @@ describe('runNonInteractive', () => {
// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'Unknown command result type: unhandled\n',
'Unknown command result type: unhandled',
);
});
it('should pass arguments to the slash command action', async () => {
setupMetricsMock();
const mockAction = vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: [{ text: 'Prompt from command' }],
@ -1825,84 +1833,4 @@ describe('runNonInteractive', () => {
{ isContinuation: false },
);
});
it('should print tool output to console in text mode (non-Task tools)', async () => {
// Test that tool output is printed to stdout in text mode
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'run_in_terminal',
args: { command: 'npm outdated' },
isClientInitiated: false,
prompt_id: 'prompt-id-tool-output',
},
};
// Mock tool execution with outputUpdateHandler being called
mockCoreExecuteToolCall.mockImplementation(
async (_config, _request, _signal, options) => {
// Simulate tool calling outputUpdateHandler with output chunks
if (options?.outputUpdateHandler) {
options.outputUpdateHandler('tool-1', 'Package outdated\n');
options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n');
}
return {
responseParts: [
{
functionResponse: {
id: 'tool-1',
name: 'run_in_terminal',
response: {
output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0',
},
},
},
],
};
},
);
const firstCallEvents: ServerGeminiStreamEvent[] = [
toolCallEvent,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
];
const secondCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
},
];
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
await runNonInteractive(
mockConfig,
mockSettings,
'Check dependencies',
'prompt-id-tool-output',
);
// Verify that executeToolCall was called with outputUpdateHandler
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ name: 'run_in_terminal' }),
expect.any(AbortSignal),
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// Verify tool output was written to stdout
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
});
});

View file

@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
ToolCallRequestInfo,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
@ -53,19 +49,12 @@ import {
async function emitNonInteractiveFinalMessage(params: {
message: string;
isError: boolean;
adapter?: JsonOutputAdapterInterface;
adapter: JsonOutputAdapterInterface;
config: Config;
startTimeMs: number;
}): Promise<void> {
const { message, isError, adapter, config } = params;
if (!adapter) {
// Text output mode: write directly to stdout/stderr
const target = isError ? process.stderr : process.stdout;
target.write(`${message}\n`);
return;
}
// JSON output mode: emit assistant message and result
// (systemMessage should already be emitted by caller)
adapter.startAssistantMessage();
@ -122,18 +111,18 @@ export async function runNonInteractive(
): Promise<void> {
return promptIdContext.run(prompt_id, async () => {
// Create output adapter based on format
let adapter: JsonOutputAdapterInterface | undefined;
let adapter: JsonOutputAdapterInterface;
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(),
);
} else {
adapter = new JsonOutputAdapter(config);
}
// Get readonly values once at the start
@ -169,14 +158,12 @@ export async function runNonInteractive(
process.on('SIGTERM', shutdownHandler);
// Emit systemMessage first (always the first message in JSON mode)
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
options.userMessage,
@ -282,46 +269,33 @@ export async function runNonInteractive(
isFirstTurn = false;
// Start assistant message for this turn
if (adapter) {
adapter.startAssistantMessage();
}
adapter.startAssistantMessage();
for await (const event of responseStream) {
if (abortController.signal.aborted) {
handleCancellationError(config);
}
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.Thought) {
process.stdout.write(event.value.description);
} else 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.Error) {
// Format and output the error message for text mode
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
}
// Use adapter for all event processing
adapter.processEvent(event);
if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
}
if (
outputFormat === OutputFormat.TEXT &&
event.type === GeminiEventType.Error
) {
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
}
}
// Finalize assistant message
if (adapter) {
adapter.finalizeAssistantMessage();
}
adapter.finalizeAssistantMessage();
totalApiDurationMs += Date.now() - apiStartTime;
if (toolCallRequests.length > 0) {
@ -350,35 +324,13 @@ export async function runNonInteractive(
: undefined;
const taskToolProgressHandler = taskToolProgress?.handler;
// Create output handler for non-Task tools in text mode (for console output)
const nonTaskOutputHandler =
!isTaskTool && !adapter
? (callId: string, outputChunk: ToolResultDisplay) => {
// Print tool output to console in text mode
if (typeof outputChunk === 'string') {
process.stdout.write(outputChunk);
} else if (
outputChunk &&
typeof outputChunk === 'object' &&
'ansiOutput' in outputChunk
) {
// Handle ANSI output - just print as string for now
process.stdout.write(String(outputChunk.ansiOutput));
}
}
: undefined;
// Combine output handlers
const outputUpdateHandler =
taskToolProgressHandler || nonTaskOutputHandler;
const toolResponse = await executeToolCall(
config,
finalRequestInfo,
abortController.signal,
outputUpdateHandler || toolCallUpdateCallback
taskToolProgressHandler || toolCallUpdateCallback
? {
...(outputUpdateHandler && { outputUpdateHandler }),
...(taskToolProgressHandler && { taskToolProgressHandler }),
...(toolCallUpdateCallback && {
onToolCallsUpdate: toolCallUpdateCallback,
}),
@ -405,9 +357,7 @@ export async function runNonInteractive(
);
}
if (adapter) {
adapter.emitToolResult(finalRequestInfo, toolResponse);
}
adapter.emitToolResult(finalRequestInfo, toolResponse);
if (toolResponse.responseParts) {
toolResponseParts.push(...toolResponse.responseParts);
@ -415,51 +365,43 @@ export async function runNonInteractive(
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
// 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 {
// Text output mode - no usage needed
process.stdout.write('\n');
}
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,
});
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,
});
}
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 {
process.stdout.removeListener('error', stdoutErrorHandler);

View file

@ -434,7 +434,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch
useEffect(() => {
// Check for initialization error first
const currentAuthType = config.modelsConfig.getCurrentAuthType();
const currentAuthType = config.getModelsConfig().getCurrentAuthType();
if (
settings.merged.security?.auth?.enforcedType &&

View file

@ -6,22 +6,21 @@
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { tokenLimit } from '@qwen-code/qwen-code-core';
export const ContextUsageDisplay = ({
promptTokenCount,
model,
terminalWidth,
contextWindowSize,
}: {
promptTokenCount: number;
model: string;
terminalWidth: number;
contextWindowSize: number;
}) => {
if (promptTokenCount === 0) {
return null;
}
const percentage = promptTokenCount / tokenLimit(model);
const percentage = promptTokenCount / contextWindowSize;
const percentageUsed = (percentage * 100).toFixed(1);
const label = terminalWidth < 100 ? '% used' : '% context used';

View file

@ -23,6 +23,7 @@ const defaultProps = {
const createMockConfig = (overrides = {}) => ({
getModel: vi.fn(() => defaultProps.model),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({ contextWindowSize: 131072 })),
getMcpServers: vi.fn(() => ({})),
getBlockedMcpServers: vi.fn(() => []),
...overrides,

View file

@ -26,13 +26,11 @@ export const Footer: React.FC = () => {
const { vimEnabled, vimMode } = useVimMode();
const {
model,
errorCount,
showErrorDetails,
promptTokenCount,
showAutoAcceptIndicator,
} = {
model: config.getModel(),
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@ -57,6 +55,9 @@ export const Footer: React.FC = () => {
// Check if debug mode is enabled
const debugMode = config.getDebugMode();
const contextWindowSize =
config.getContentGeneratorConfig()?.contextWindowSize;
// Left section should show exactly ONE thing at any time, in priority order.
const leftContent = uiState.ctrlCPressedOnce ? (
<Text color={theme.status.warning}>{t('Press Ctrl+C again to exit.')}</Text>
@ -88,15 +89,15 @@ export const Footer: React.FC = () => {
node: <Text color={theme.status.warning}>Debug Mode</Text>,
});
}
if (promptTokenCount > 0) {
if (promptTokenCount > 0 && contextWindowSize) {
rightItems.push({
key: 'context',
node: (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
contextWindowSize={contextWindowSize}
/>
</Text>
),

View file

@ -984,26 +984,6 @@ describe('createTaskToolProgressHandler', () => {
expect(mockAdapter.emitToolResult).not.toHaveBeenCalled();
});
it('should work without adapter (non-JSON mode)', () => {
const { handler } = createTaskToolProgressHandler(
mockConfig,
'parent-tool-id',
undefined,
);
const taskDisplay: TaskResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
taskPrompt: 'Test prompt',
status: 'running',
toolCalls: [],
};
// Should not throw
expect(() => handler('task-call-id', taskDisplay)).not.toThrow();
});
it('should work with adapter that does not support subagent APIs', () => {
const limitedAdapter = {
emitToolResult: vi.fn(),

View file

@ -306,7 +306,7 @@ export async function buildSystemMessage(
export function createTaskToolProgressHandler(
config: Config,
taskToolCallId: string,
adapter: JsonOutputAdapterInterface | undefined,
adapter: JsonOutputAdapterInterface,
): {
handler: OutputUpdateHandler;
} {
@ -406,7 +406,7 @@ export function createTaskToolProgressHandler(
toolCallToEmit.status === 'executing' ||
toolCallToEmit.status === 'awaiting_approval'
) {
if (adapter?.processSubagentToolCall) {
if (adapter.processSubagentToolCall) {
adapter.processSubagentToolCall(toolCallToEmit, taskToolCallId);
emittedToolUseIds.add(toolCall.callId);
}
@ -432,19 +432,17 @@ export function createTaskToolProgressHandler(
// 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);
}
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);
}
};
@ -501,12 +499,6 @@ export function createTaskToolProgressHandler(
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 ||

View file

@ -14,18 +14,24 @@ import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
import * as cleanupModule from './utils/cleanup.js';
type ModelsConfig = ReturnType<Config['getModelsConfig']>;
// Helper to create a mock Config with modelsConfig
function createMockConfig(overrides?: Partial<Config>): Config {
return {
const baseModelsConfig = {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
} as unknown as ModelsConfig;
const baseConfig: Partial<Config> = {
refreshAuth: vi.fn().mockResolvedValue('refreshed'),
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: undefined }),
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
getModelsConfig: vi.fn().mockReturnValue(baseModelsConfig),
};
return {
...baseConfig,
...overrides,
} as unknown as Config;
} as Config;
}
describe('validateNonInterActiveAuth', () => {
@ -128,10 +134,10 @@ describe('validateNonInterActiveAuth', () => {
);
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
}),
});
try {
await validateNonInteractiveAuth(
@ -153,10 +159,10 @@ describe('validateNonInterActiveAuth', () => {
process.env['OPENAI_API_KEY'] = 'fake-openai-key';
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
await validateNonInteractiveAuth(
undefined,
@ -169,10 +175,10 @@ describe('validateNonInterActiveAuth', () => {
it('uses configured QWEN_OAUTH if provided', async () => {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
}),
});
await validateNonInteractiveAuth(
undefined,
@ -222,7 +228,7 @@ describe('validateNonInterActiveAuth', () => {
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
// refreshAuth is called with the authType from config.modelsConfig.getCurrentAuthType()
// refreshAuth is called with the authType from config.getModelsConfig().getCurrentAuthType()
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
});
@ -233,10 +239,10 @@ describe('validateNonInterActiveAuth', () => {
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
await validateNonInteractiveAuth(
undefined,
@ -251,10 +257,10 @@ describe('validateNonInterActiveAuth', () => {
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {
await validateNonInteractiveAuth(
@ -297,10 +303,10 @@ describe('validateNonInterActiveAuth', () => {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
}),
});
try {
@ -334,10 +340,10 @@ describe('validateNonInterActiveAuth', () => {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {
@ -373,10 +379,10 @@ describe('validateNonInterActiveAuth', () => {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {
@ -433,10 +439,10 @@ describe('validateNonInterActiveAuth', () => {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
}),
});
try {
@ -471,10 +477,10 @@ describe('validateNonInterActiveAuth', () => {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {
@ -511,10 +517,10 @@ describe('validateNonInterActiveAuth', () => {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {

View file

@ -19,7 +19,9 @@ export async function validateNonInteractiveAuth(
): Promise<Config> {
try {
// Get the actual authType from config which has already resolved CLI args, env vars, and settings
const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType();
const authType = nonInteractiveConfig
.getModelsConfig()
.getCurrentAuthType();
if (!authType) {
throw new Error(
'No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.',