mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
Merge branch 'main' into pr-1539
This commit is contained in:
commit
1c5b74ebd9
210 changed files with 22365 additions and 2747 deletions
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue