mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 19:52:02 +00:00
Merge branch 'main' into feat/image-attachment
This commit is contained in:
commit
c92e2b8351
301 changed files with 33924 additions and 5940 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -488,6 +488,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
excludeTools: undefined,
|
||||
authType: undefined,
|
||||
maxSessionTurns: undefined,
|
||||
experimentalLsp: undefined,
|
||||
channel: undefined,
|
||||
chatRecording: undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue