Merge branch 'main' into pr-1539

This commit is contained in:
tanzhenxin 2026-01-29 19:18:23 +08:00
commit 1c5b74ebd9
210 changed files with 22365 additions and 2747 deletions

View file

@ -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.',

View file

@ -366,6 +366,9 @@ export type Usage = z.infer<typeof usageSchema>;
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>;
@ -573,6 +576,7 @@ export const sessionUpdateSchema = z.union([
kind: toolKindSchema,
locations: z.array(toolCallLocationSchema).optional(),
rawInput: z.unknown().optional(),
_meta: sessionUpdateMetaSchema.optional().nullable(),
sessionUpdate: z.literal('tool_call'),
status: toolCallStatusSchema,
title: z.string(),
@ -584,6 +588,7 @@ export const sessionUpdateSchema = z.union([
locations: z.array(toolCallLocationSchema).optional().nullable(),
rawInput: z.unknown().optional(),
rawOutput: z.unknown().optional(),
_meta: sessionUpdateMetaSchema.optional().nullable(),
sessionUpdate: z.literal('tool_call_update'),
status: toolCallStatusSchema.optional().nullable(),
title: z.string().optional().nullable(),

View file

@ -228,6 +228,7 @@ describe('HistoryReplayer', () => {
status: 'in_progress',
title: 'read_file',
rawInput: { path: '/test.ts' },
_meta: { toolName: 'read_file' },
}),
);
});
@ -280,6 +281,7 @@ describe('HistoryReplayer', () => {
],
// resultDisplay is included as rawOutput
rawOutput: 'File contents here',
_meta: { toolName: 'read_file' },
});
});

View file

@ -5,6 +5,9 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { Session } from './Session.js';
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
@ -38,10 +41,27 @@ describe('Session', () => {
addHistory: vi.fn(),
} as unknown as GeminiChat;
const toolRegistry = { getTool: vi.fn() };
const fileService = { shouldGitIgnoreFile: vi.fn().mockReturnValue(false) };
mockConfig = {
setApprovalMode: vi.fn(),
setModel: setModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
getChatRecordingService: vi.fn().mockReturnValue({
recordUserMessage: vi.fn(),
recordUiTelemetryEvent: vi.fn(),
}),
getToolRegistry: vi.fn().mockReturnValue(toolRegistry),
getFileService: vi.fn().mockReturnValue(fileService),
getFileFilteringRespectGitIgnore: vi.fn().mockReturnValue(true),
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
getTargetDir: vi.fn().mockReturnValue(process.cwd()),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockClient = {
@ -171,4 +191,61 @@ describe('Session', () => {
consoleErrorSpy.mockRestore();
});
});
describe('prompt', () => {
it('passes resolved paths to read_many_files tool', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-session-'),
);
const fileName = 'README.md';
const filePath = path.join(tempDir, fileName);
try {
await fs.writeFile(filePath, '# Test\n', 'utf8');
const readManyFilesTool = {
buildAndExecute: vi.fn().mockResolvedValue({
llmContent: 'file content',
returnDisplay: 'ok',
}),
};
const toolRegistry = {
getTool: vi.fn((name: string) =>
name === 'read_many_files' ? readManyFilesTool : undefined,
),
};
const fileService = {
shouldGitIgnoreFile: vi.fn().mockReturnValue(false),
};
mockConfig.getTargetDir = vi.fn().mockReturnValue(tempDir);
mockConfig.getToolRegistry = vi.fn().mockReturnValue(toolRegistry);
mockConfig.getFileService = vi.fn().mockReturnValue(fileService);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue((async function* () {})());
const promptRequest: acp.PromptRequest = {
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'Check this file' },
{
type: 'resource_link',
name: fileName,
uri: `file://${fileName}`,
},
],
};
await session.prompt(promptRequest);
expect(readManyFilesTool.buildAndExecute).toHaveBeenCalledWith(
{ paths: [fileName] },
expect.any(AbortSignal),
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
});

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(
@ -647,7 +656,11 @@ export class Session implements SessionContext {
const error = e instanceof Error ? e : new Error(String(e));
// Use ToolCallEmitter for error handling
await this.toolCallEmitter.emitError(callId, error);
await this.toolCallEmitter.emitError(
callId,
fc.name ?? 'unknown_tool',
error,
);
// Record tool error for session management
const errorParts = [
@ -979,7 +992,7 @@ export class Session implements SessionContext {
if (pathSpecsToRead.length > 0) {
const readResult = await readManyFilesTool.buildAndExecute(
{
paths_with_line_ranges: pathSpecsToRead,
paths: pathSpecsToRead,
},
abortSignal,
);

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

@ -77,6 +77,7 @@ describe('ToolCallEmitter', () => {
locations: [],
kind: 'other',
rawInput: { arg1: 'value1' },
_meta: { toolName: 'unknown_tool' },
});
});
@ -100,6 +101,7 @@ describe('ToolCallEmitter', () => {
locations: [{ path: '/test/file.ts', line: 10 }],
kind: 'edit',
rawInput: { path: '/test.ts' },
_meta: { toolName: 'edit_file' },
});
});
@ -123,6 +125,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
rawInput: {},
_meta: { toolName: 'test_tool' },
}),
);
});
@ -150,6 +153,7 @@ describe('ToolCallEmitter', () => {
locations: [], // Fallback to empty
kind: 'other', // Fallback to other
rawInput: { invalid: true },
_meta: { toolName: 'failing_tool' },
});
});
});
@ -170,6 +174,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-123',
status: 'completed',
rawOutput: 'Tool completed successfully',
_meta: { toolName: 'test_tool' },
}),
);
});
@ -193,6 +198,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Something went wrong' },
},
],
_meta: { toolName: 'test_tool' },
});
});
@ -222,6 +228,7 @@ describe('ToolCallEmitter', () => {
newText: 'new content',
},
],
_meta: { toolName: 'edit_file' },
}),
);
});
@ -247,6 +254,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: 'raw output',
_meta: { toolName: 'test_tool' },
}),
);
});
@ -264,6 +272,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-empty',
status: 'completed',
content: [],
_meta: { toolName: 'test_tool' },
});
});
@ -343,7 +352,7 @@ describe('ToolCallEmitter', () => {
it('should emit tool_call_update with failed status and error message', async () => {
const error = new Error('Connection timeout');
await emitter.emitError('call-123', error);
await emitter.emitError('call-123', 'test_tool', error);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call_update',
@ -355,6 +364,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Connection timeout' },
},
],
_meta: { toolName: 'test_tool' },
});
});
});
@ -498,6 +508,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: { unknownField: 'value', nested: { data: 123 } },
_meta: { toolName: 'test_tool' },
}),
);
});
@ -519,6 +530,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-extra',
status: 'completed',
rawOutput: 'Result text',
_meta: { toolName: 'test_tool' },
}),
);
});
@ -533,6 +545,7 @@ describe('ToolCallEmitter', () => {
const call = sendUpdateSpy.mock.calls[0][0];
expect(call.rawOutput).toBeUndefined();
expect(call._meta).toEqual({ toolName: 'test_tool' });
});
});
@ -623,6 +636,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Text content from message' },
},
],
_meta: { toolName: 'test_tool' },
});
});
@ -654,6 +668,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: 'raw result',
_meta: { toolName: 'test_tool' },
}),
);
});

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,6 +66,10 @@ export class ToolCallEmitter extends BaseEmitter {
locations,
kind,
rawInput: params.args ?? {},
_meta: {
toolName: params.toolName,
...params.subagentMeta,
},
});
return true;
@ -120,6 +125,10 @@ export class ToolCallEmitter extends BaseEmitter {
toolCallId: params.callId,
status: params.success ? 'completed' : 'failed',
content: contentArray,
_meta: {
toolName: params.toolName,
...params.subagentMeta,
},
};
// Add rawOutput from resultDisplay
@ -135,9 +144,16 @@ 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, error: Error): Promise<void> {
async emitError(
callId: string,
toolName: string,
error: Error,
subagentMeta?: SubagentMeta,
): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
@ -145,6 +161,10 @@ export class ToolCallEmitter extends BaseEmitter {
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
_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;
}
/**