Merge pull request #1176 from QwenLM/feat/acp-usage-metadata

Feat/acp usage metadata
This commit is contained in:
tanzhenxin
2025-12-11 14:09:43 +08:00
committed by GitHub
20 changed files with 503 additions and 35 deletions

View File

@@ -25,6 +25,14 @@ type PendingRequest = {
timeout: NodeJS.Timeout; timeout: NodeJS.Timeout;
}; };
type UsageMetadata = {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
};
type SessionUpdateNotification = { type SessionUpdateNotification = {
sessionId?: string; sessionId?: string;
update?: { update?: {
@@ -39,6 +47,9 @@ type SessionUpdateNotification = {
text?: string; text?: string;
}; };
modeId?: string; modeId?: string;
_meta?: {
usage?: UsageMetadata;
};
}; };
}; };
@@ -587,4 +598,52 @@ function setupAcpTest(
await cleanup(); await cleanup();
} }
}); });
it('receives usage metadata in agent_message_chunk updates', async () => {
const rig = new TestRig();
rig.setup('acp usage metadata');
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
try {
await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say "hello".' }],
});
await delay(500);
// Find updates with usage metadata
const updatesWithUsage = sessionUpdates.filter(
(u) =>
u.update?.sessionUpdate === 'agent_message_chunk' &&
u.update?._meta?.usage,
);
expect(updatesWithUsage.length).toBeGreaterThan(0);
const usage = updatesWithUsage[0].update?._meta?.usage;
expect(usage).toBeDefined();
expect(
typeof usage?.promptTokens === 'number' ||
typeof usage?.totalTokens === 'number',
).toBe(true);
} catch (e) {
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
throw e;
} finally {
await cleanup();
}
});
}); });

View File

@@ -316,6 +316,23 @@ export const annotationsSchema = z.object({
priority: z.number().optional().nullable(), priority: z.number().optional().nullable(),
}); });
export const usageSchema = z.object({
promptTokens: z.number().optional().nullable(),
completionTokens: z.number().optional().nullable(),
thoughtsTokens: z.number().optional().nullable(),
totalTokens: z.number().optional().nullable(),
cachedTokens: z.number().optional().nullable(),
});
export type Usage = z.infer<typeof usageSchema>;
export const sessionUpdateMetaSchema = z.object({
usage: usageSchema.optional().nullable(),
durationMs: z.number().optional().nullable(),
});
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
export const requestPermissionResponseSchema = z.object({ export const requestPermissionResponseSchema = z.object({
outcome: requestPermissionOutcomeSchema, outcome: requestPermissionOutcomeSchema,
}); });
@@ -500,10 +517,12 @@ export const sessionUpdateSchema = z.union([
z.object({ z.object({
content: contentBlockSchema, content: contentBlockSchema,
sessionUpdate: z.literal('agent_message_chunk'), sessionUpdate: z.literal('agent_message_chunk'),
_meta: sessionUpdateMetaSchema.optional().nullable(),
}), }),
z.object({ z.object({
content: contentBlockSchema, content: contentBlockSchema,
sessionUpdate: z.literal('agent_thought_chunk'), sessionUpdate: z.literal('agent_thought_chunk'),
_meta: sessionUpdateMetaSchema.optional().nullable(),
}), }),
z.object({ z.object({
content: z.array(toolCallContentSchema).optional(), content: z.array(toolCallContentSchema).optional(),

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import { AcpFileSystemService } from './filesystem.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
findFiles: vi.fn().mockReturnValue([]),
});
describe('AcpFileSystemService', () => {
describe('readTextFile ENOENT handling', () => {
it('parses path from ACP ENOENT message (quoted)', async () => {
const client = {
readTextFile: vi
.fn()
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-1',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
code: 'ENOENT',
path: '/remote/file.txt',
});
});
it('falls back to requested path when none provided', async () => {
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-2',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(
svc.readTextFile('/fallback/path.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
path: '/fallback/path.txt',
});
});
});
});

View File

@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
limit: null, limit: null,
}); });
if (response.content.startsWith('ERROR: ENOENT:')) {
// Treat ACP error strings as structured ENOENT errors without
// assuming a specific platform format.
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
const err = new Error(response.content) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
const rawPath = match?.groups?.['path']?.trim();
err['path'] = rawPath
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
: filePath;
throw err;
}
return response.content; return response.content;
} }

View File

@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
]); ]);
}); });
}); });
describe('usage metadata replay', () => {
it('should emit usage metadata after assistant message content', async () => {
const record: ChatRecord = {
uuid: 'assistant-uuid',
parentUuid: 'user-uuid',
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'assistant',
cwd: '/test',
version: '1.0.0',
message: {
role: 'model',
parts: [{ text: 'Hello!' }],
},
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello!' },
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
thoughtsTokens: undefined,
totalTokens: 150,
cachedTokens: undefined,
},
},
});
});
});
}); });

View File

@@ -4,8 +4,11 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { ChatRecord } from '@qwen-code/qwen-code-core'; import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai'; import type {
Content,
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import type { SessionContext } from './types.js'; import type { SessionContext } from './types.js';
import { MessageEmitter } from './emitters/MessageEmitter.js'; import { MessageEmitter } from './emitters/MessageEmitter.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
@@ -52,6 +55,9 @@ export class HistoryReplayer {
if (record.message) { if (record.message) {
await this.replayContent(record.message, 'assistant'); await this.replayContent(record.message, 'assistant');
} }
if (record.usageMetadata) {
await this.replayUsageMetadata(record.usageMetadata);
}
break; break;
case 'tool_result': case 'tool_result':
@@ -88,11 +94,22 @@ export class HistoryReplayer {
toolName: functionName, toolName: functionName,
callId, callId,
args: part.functionCall.args as Record<string, unknown>, args: part.functionCall.args as Record<string, unknown>,
status: 'in_progress',
}); });
} }
} }
} }
/**
* Replays usage metadata.
* @param usageMetadata - The usage metadata to replay
*/
private async replayUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
): Promise<void> {
await this.messageEmitter.emitUsageMetadata(usageMetadata);
}
/** /**
* Replays a tool result record. * Replays a tool result record.
*/ */
@@ -118,6 +135,54 @@ export class HistoryReplayer {
// Note: args aren't stored in tool_result records by default // Note: args aren't stored in tool_result records by default
args: undefined, args: undefined,
}); });
// Special handling: Task tool execution summary contains token usage
const { resultDisplay } = result ?? {};
if (
!!resultDisplay &&
typeof resultDisplay === 'object' &&
'type' in resultDisplay &&
(resultDisplay as { type?: unknown }).type === 'task_execution'
) {
await this.emitTaskUsageFromResultDisplay(
resultDisplay as TaskResultDisplay,
);
}
}
/**
* Emits token usage from a TaskResultDisplay execution summary, if present.
*/
private async emitTaskUsageFromResultDisplay(
resultDisplay: TaskResultDisplay,
): Promise<void> {
const summary = resultDisplay.executionSummary;
if (!summary) {
return;
}
const usageMetadata: GenerateContentResponseUsageMetadata = {};
if (Number.isFinite(summary.inputTokens)) {
usageMetadata.promptTokenCount = summary.inputTokens;
}
if (Number.isFinite(summary.outputTokens)) {
usageMetadata.candidatesTokenCount = summary.outputTokens;
}
if (Number.isFinite(summary.thoughtTokens)) {
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
}
if (Number.isFinite(summary.cachedTokens)) {
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
}
if (Number.isFinite(summary.totalTokens)) {
usageMetadata.totalTokenCount = summary.totalTokens;
}
// Only emit if we captured at least one token metric
if (Object.keys(usageMetadata).length > 0) {
await this.messageEmitter.emitUsageMetadata(usageMetadata);
}
} }
/** /**

View File

@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { Content, FunctionCall, Part } from '@google/genai'; import type {
Content,
FunctionCall,
GenerateContentResponseUsageMetadata,
Part,
} from '@google/genai';
import type { import type {
Config, Config,
GeminiChat, GeminiChat,
@@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
import { HistoryReplayer } from './HistoryReplayer.js'; import { HistoryReplayer } from './HistoryReplayer.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { PlanEmitter } from './emitters/PlanEmitter.js'; import { PlanEmitter } from './emitters/PlanEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { SubAgentTracker } from './SubAgentTracker.js'; import { SubAgentTracker } from './SubAgentTracker.js';
/** /**
@@ -79,6 +85,7 @@ export class Session implements SessionContext {
private readonly historyReplayer: HistoryReplayer; private readonly historyReplayer: HistoryReplayer;
private readonly toolCallEmitter: ToolCallEmitter; private readonly toolCallEmitter: ToolCallEmitter;
private readonly planEmitter: PlanEmitter; private readonly planEmitter: PlanEmitter;
private readonly messageEmitter: MessageEmitter;
// Implement SessionContext interface // Implement SessionContext interface
readonly sessionId: string; readonly sessionId: string;
@@ -96,6 +103,7 @@ export class Session implements SessionContext {
this.toolCallEmitter = new ToolCallEmitter(this); this.toolCallEmitter = new ToolCallEmitter(this);
this.planEmitter = new PlanEmitter(this); this.planEmitter = new PlanEmitter(this);
this.historyReplayer = new HistoryReplayer(this); this.historyReplayer = new HistoryReplayer(this);
this.messageEmitter = new MessageEmitter(this);
} }
getId(): string { getId(): string {
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
} }
const functionCalls: FunctionCall[] = []; const functionCalls: FunctionCall[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
const streamStartTime = Date.now();
try { try {
const responseStream = await chat.sendMessageStream( const responseStream = await chat.sendMessageStream(
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
continue; continue;
} }
const content: acp.ContentBlock = { this.messageEmitter.emitMessage(
type: 'text', part.text,
text: part.text, 'assistant',
}; part.thought,
);
this.sendUpdate({
sessionUpdate: part.thought
? 'agent_thought_chunk'
: 'agent_message_chunk',
content,
});
} }
} }
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
usageMetadata = resp.value.usageMetadata;
}
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
functionCalls.push(...resp.value.functionCalls); functionCalls.push(...resp.value.functionCalls);
} }
@@ -251,6 +259,15 @@ export class Session implements SessionContext {
throw error; throw error;
} }
if (usageMetadata) {
const durationMs = Date.now() - streamStartTime;
await this.messageEmitter.emitUsageMetadata(
usageMetadata,
'',
durationMs,
);
}
if (functionCalls.length > 0) { if (functionCalls.length > 0) {
const toolResponseParts: Part[] = []; const toolResponseParts: Part[] = [];
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
} }
const confirmationDetails = const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal); this.config.getApprovalMode() !== ApprovalMode.YOLO
? await invocation.shouldConfirmExecute(abortSignal)
: false;
if (confirmationDetails) { if (confirmationDetails) {
const content: acp.ToolCallContent[] = []; const content: acp.ToolCallContent[] = [];
@@ -522,6 +541,7 @@ export class Session implements SessionContext {
callId, callId,
toolName: fc.name, toolName: fc.name,
args, args,
status: 'in_progress',
}; };
await this.toolCallEmitter.emitStart(startParams); await this.toolCallEmitter.emitStart(startParams);
} }

View File

@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
expect.objectContaining({ expect.objectContaining({
sessionUpdate: 'tool_call', sessionUpdate: 'tool_call',
toolCallId: 'call-123', toolCallId: 'call-123',
status: 'in_progress', status: 'pending',
title: 'read_file', title: 'read_file',
content: [], content: [],
locations: [], locations: [],

View File

@@ -9,6 +9,7 @@ import type {
SubAgentToolCallEvent, SubAgentToolCallEvent,
SubAgentToolResultEvent, SubAgentToolResultEvent,
SubAgentApprovalRequestEvent, SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
AnyDeclarativeTool, AnyDeclarativeTool,
AnyToolInvocation, AnyToolInvocation,
@@ -20,6 +21,7 @@ import {
import { z } from 'zod'; import { z } from 'zod';
import type { SessionContext } from './types.js'; import type { SessionContext } from './types.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import type * as acp from '../acp.js'; import type * as acp from '../acp.js';
/** /**
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
*/ */
export class SubAgentTracker { export class SubAgentTracker {
private readonly toolCallEmitter: ToolCallEmitter; private readonly toolCallEmitter: ToolCallEmitter;
private readonly messageEmitter: MessageEmitter;
private readonly toolStates = new Map< private readonly toolStates = new Map<
string, string,
{ {
@@ -76,6 +79,7 @@ export class SubAgentTracker {
private readonly client: acp.Client, private readonly client: acp.Client,
) { ) {
this.toolCallEmitter = new ToolCallEmitter(ctx); this.toolCallEmitter = new ToolCallEmitter(ctx);
this.messageEmitter = new MessageEmitter(ctx);
} }
/** /**
@@ -92,16 +96,19 @@ export class SubAgentTracker {
const onToolCall = this.createToolCallHandler(abortSignal); const onToolCall = this.createToolCallHandler(abortSignal);
const onToolResult = this.createToolResultHandler(abortSignal); const onToolResult = this.createToolResultHandler(abortSignal);
const onApproval = this.createApprovalHandler(abortSignal); const onApproval = this.createApprovalHandler(abortSignal);
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
return [ return [
() => { () => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
// Clean up any remaining states // Clean up any remaining states
this.toolStates.clear(); this.toolStates.clear();
}, },
@@ -252,6 +259,20 @@ export class SubAgentTracker {
}; };
} }
/**
* Creates a handler for usage metadata events.
*/
private createUsageMetadataHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
};
}
/** /**
* Converts confirmation details to permission options for the client. * Converts confirmation details to permission options for the client.
*/ */

View File

@@ -148,4 +148,59 @@ describe('MessageEmitter', () => {
}); });
}); });
}); });
describe('emitUsageMetadata', () => {
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
const usageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 50,
thoughtsTokenCount: 25,
totalTokenCount: 175,
cachedContentTokenCount: 10,
};
await emitter.emitUsageMetadata(usageMetadata);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
thoughtsTokens: 25,
totalTokens: 175,
cachedTokens: 10,
},
},
});
});
it('should include durationMs in _meta when provided', async () => {
const usageMetadata = {
promptTokenCount: 10,
candidatesTokenCount: 5,
thoughtsTokenCount: 2,
totalTokenCount: 17,
cachedContentTokenCount: 1,
};
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'done' },
_meta: {
usage: {
promptTokens: 10,
completionTokens: 5,
thoughtsTokens: 2,
totalTokens: 17,
cachedTokens: 1,
},
durationMs: 1234,
},
});
});
});
}); });

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { Usage } from '../../schema.js';
import { BaseEmitter } from './BaseEmitter.js'; import { BaseEmitter } from './BaseEmitter.js';
/** /**
@@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter {
}); });
} }
/**
* Emits an agent thought chunk.
*/
async emitAgentThought(text: string): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
});
}
/** /**
* Emits an agent message chunk. * Emits an agent message chunk.
*/ */
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
} }
/** /**
* Emits an agent thought chunk. * Emits usage metadata.
*/ */
async emitAgentThought(text: string): Promise<void> { async emitUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
): Promise<void> {
const usage: Usage = {
promptTokens: usageMetadata.promptTokenCount,
completionTokens: usageMetadata.candidatesTokenCount,
thoughtsTokens: usageMetadata.thoughtsTokenCount,
totalTokens: usageMetadata.totalTokenCount,
cachedTokens: usageMetadata.cachedContentTokenCount,
};
const meta =
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
await this.sendUpdate({ await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk', sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text }, content: { type: 'text', text },
_meta: meta,
}); });
} }

View File

@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({ expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call', sessionUpdate: 'tool_call',
toolCallId: 'call-123', toolCallId: 'call-123',
status: 'in_progress', status: 'pending',
title: 'unknown_tool', // Falls back to tool name title: 'unknown_tool', // Falls back to tool name
content: [], content: [],
locations: [], locations: [],
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({ expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call', sessionUpdate: 'tool_call',
toolCallId: 'call-456', toolCallId: 'call-456',
status: 'in_progress', status: 'pending',
title: 'edit_file: Test tool description', title: 'edit_file: Test tool description',
content: [], content: [],
locations: [{ path: '/test/file.ts', line: 10 }], locations: [{ path: '/test/file.ts', line: 10 }],
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({ expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call', sessionUpdate: 'tool_call',
toolCallId: 'call-fail', toolCallId: 'call-fail',
status: 'in_progress', status: 'pending',
title: 'failing_tool', // Fallback to tool name title: 'failing_tool', // Fallback to tool name
content: [], content: [],
locations: [], // Fallback to empty locations: [], // Fallback to empty
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
type: 'content', type: 'content',
content: { content: {
type: 'text', type: 'text',
text: '{"output":"test output"}', text: 'test output',
}, },
}, },
], ],
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
content: [ content: [
{ {
type: 'content', type: 'content',
content: { type: 'text', text: '{"output":"Function output"}' }, content: { type: 'text', text: 'Function output' },
}, },
], ],
rawOutput: 'raw result', rawOutput: 'raw result',

View File

@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
await this.sendUpdate({ await this.sendUpdate({
sessionUpdate: 'tool_call', sessionUpdate: 'tool_call',
toolCallId: params.callId, toolCallId: params.callId,
status: 'in_progress', status: params.status || 'pending',
title, title,
content: [], content: [],
locations, locations,
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
// Handle functionResponse parts - stringify the response // Handle functionResponse parts - stringify the response
if ('functionResponse' in part && part.functionResponse) { if ('functionResponse' in part && part.functionResponse) {
try { try {
const responseText = JSON.stringify(part.functionResponse.response); const resp = part.functionResponse.response as Record<
string,
unknown
>;
const outputField = resp['output'];
const errorField = resp['error'];
const responseText =
typeof outputField === 'string'
? outputField
: typeof errorField === 'string'
? errorField
: JSON.stringify(resp);
result.push({ result.push({
type: 'content', type: 'content',
content: { type: 'text', text: responseText }, content: { type: 'text', text: responseText },

View File

@@ -35,6 +35,8 @@ export interface ToolCallStartParams {
callId: string; callId: string;
/** Arguments passed to the tool */ /** Arguments passed to the tool */
args?: Record<string, unknown>; args?: Record<string, unknown>;
/** Status of the tool call */
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
} }
/** /**

View File

@@ -58,6 +58,7 @@ export type {
SubAgentStartEvent, SubAgentStartEvent,
SubAgentRoundEvent, SubAgentRoundEvent,
SubAgentStreamTextEvent, SubAgentStreamTextEvent,
SubAgentUsageEvent,
SubAgentToolCallEvent, SubAgentToolCallEvent,
SubAgentToolResultEvent, SubAgentToolResultEvent,
SubAgentFinishEvent, SubAgentFinishEvent,

View File

@@ -10,7 +10,7 @@ import type {
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolResultDisplay, ToolResultDisplay,
} from '../tools/tools.js'; } from '../tools/tools.js';
import type { Part } from '@google/genai'; import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
export type SubAgentEvent = export type SubAgentEvent =
| 'start' | 'start'
@@ -20,6 +20,7 @@ export type SubAgentEvent =
| 'tool_call' | 'tool_call'
| 'tool_result' | 'tool_result'
| 'tool_waiting_approval' | 'tool_waiting_approval'
| 'usage_metadata'
| 'finish' | 'finish'
| 'error'; | 'error';
@@ -31,6 +32,7 @@ export enum SubAgentEventType {
TOOL_CALL = 'tool_call', TOOL_CALL = 'tool_call',
TOOL_RESULT = 'tool_result', TOOL_RESULT = 'tool_result',
TOOL_WAITING_APPROVAL = 'tool_waiting_approval', TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
USAGE_METADATA = 'usage_metadata',
FINISH = 'finish', FINISH = 'finish',
ERROR = 'error', ERROR = 'error',
} }
@@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent {
timestamp: number; timestamp: number;
} }
export interface SubAgentUsageEvent {
subagentId: string;
round: number;
usage: GenerateContentResponseUsageMetadata;
durationMs?: number;
timestamp: number;
}
export interface SubAgentToolCallEvent { export interface SubAgentToolCallEvent {
subagentId: string; subagentId: string;
round: number; round: number;

View File

@@ -50,6 +50,15 @@ describe('SubagentStatistics', () => {
expect(summary.outputTokens).toBe(600); expect(summary.outputTokens).toBe(600);
expect(summary.totalTokens).toBe(1800); expect(summary.totalTokens).toBe(1800);
}); });
it('should track thought and cached tokens', () => {
stats.recordTokens(100, 50, 10, 5);
const summary = stats.getSummary();
expect(summary.thoughtTokens).toBe(10);
expect(summary.cachedTokens).toBe(5);
expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5
});
}); });
describe('tool usage statistics', () => { describe('tool usage statistics', () => {
@@ -93,14 +102,14 @@ describe('SubagentStatistics', () => {
stats.start(baseTime); stats.start(baseTime);
stats.setRounds(2); stats.setRounds(2);
stats.recordToolCall('file_read', true, 100); stats.recordToolCall('file_read', true, 100);
stats.recordTokens(1000, 500); stats.recordTokens(1000, 500, 20, 10);
const result = stats.formatCompact('Test task', baseTime + 5000); const result = stats.formatCompact('Test task', baseTime + 5000);
expect(result).toContain('📋 Task Completed: Test task'); expect(result).toContain('📋 Task Completed: Test task');
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success'); expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2'); expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)'); expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)');
}); });
it('should handle zero tool calls', () => { it('should handle zero tool calls', () => {

View File

@@ -23,6 +23,8 @@ export interface SubagentStatsSummary {
successRate: number; successRate: number;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
thoughtTokens: number;
cachedTokens: number;
totalTokens: number; totalTokens: number;
estimatedCost: number; estimatedCost: number;
toolUsage: ToolUsageStats[]; toolUsage: ToolUsageStats[];
@@ -36,6 +38,8 @@ export class SubagentStatistics {
private failedToolCalls = 0; private failedToolCalls = 0;
private inputTokens = 0; private inputTokens = 0;
private outputTokens = 0; private outputTokens = 0;
private thoughtTokens = 0;
private cachedTokens = 0;
private toolUsage = new Map<string, ToolUsageStats>(); private toolUsage = new Map<string, ToolUsageStats>();
start(now = Date.now()) { start(now = Date.now()) {
@@ -74,9 +78,16 @@ export class SubagentStatistics {
this.toolUsage.set(name, tu); this.toolUsage.set(name, tu);
} }
recordTokens(input: number, output: number) { recordTokens(
input: number,
output: number,
thought: number = 0,
cached: number = 0,
) {
this.inputTokens += Math.max(0, input || 0); this.inputTokens += Math.max(0, input || 0);
this.outputTokens += Math.max(0, output || 0); this.outputTokens += Math.max(0, output || 0);
this.thoughtTokens += Math.max(0, thought || 0);
this.cachedTokens += Math.max(0, cached || 0);
} }
getSummary(now = Date.now()): SubagentStatsSummary { getSummary(now = Date.now()): SubagentStatsSummary {
@@ -86,7 +97,11 @@ export class SubagentStatistics {
totalToolCalls > 0 totalToolCalls > 0
? (this.successfulToolCalls / totalToolCalls) * 100 ? (this.successfulToolCalls / totalToolCalls) * 100
: 0; : 0;
const totalTokens = this.inputTokens + this.outputTokens; const totalTokens =
this.inputTokens +
this.outputTokens +
this.thoughtTokens +
this.cachedTokens;
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5; const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
return { return {
rounds: this.rounds, rounds: this.rounds,
@@ -97,6 +112,8 @@ export class SubagentStatistics {
successRate, successRate,
inputTokens: this.inputTokens, inputTokens: this.inputTokens,
outputTokens: this.outputTokens, outputTokens: this.outputTokens,
thoughtTokens: this.thoughtTokens,
cachedTokens: this.cachedTokens,
totalTokens, totalTokens,
estimatedCost, estimatedCost,
toolUsage: Array.from(this.toolUsage.values()), toolUsage: Array.from(this.toolUsage.values()),
@@ -116,8 +133,12 @@ export class SubagentStatistics {
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`, `⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
]; ];
if (typeof stats.totalTokens === 'number') { if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push( lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`, `🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`,
); );
} }
return lines.join('\n'); return lines.join('\n');
@@ -152,8 +173,12 @@ export class SubagentStatistics {
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`, `🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
); );
if (typeof stats.totalTokens === 'number') { if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push( lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`, `🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`,
); );
} }
if (stats.toolUsage && stats.toolUsage.length) { if (stats.toolUsage && stats.toolUsage.length) {

View File

@@ -41,6 +41,7 @@ import type {
SubAgentToolResultEvent, SubAgentToolResultEvent,
SubAgentStreamTextEvent, SubAgentStreamTextEvent,
SubAgentErrorEvent, SubAgentErrorEvent,
SubAgentUsageEvent,
} from './subagent-events.js'; } from './subagent-events.js';
import { import {
type SubAgentEventEmitter, type SubAgentEventEmitter,
@@ -369,6 +370,7 @@ export class SubAgentScope {
}, },
}; };
const roundStreamStart = Date.now();
const responseStream = await chat.sendMessageStream( const responseStream = await chat.sendMessageStream(
this.modelConfig.model || this.modelConfig.model ||
this.runtimeContext.getModel() || this.runtimeContext.getModel() ||
@@ -439,10 +441,19 @@ export class SubAgentScope {
if (lastUsage) { if (lastUsage) {
const inTok = Number(lastUsage.promptTokenCount || 0); const inTok = Number(lastUsage.promptTokenCount || 0);
const outTok = Number(lastUsage.candidatesTokenCount || 0); const outTok = Number(lastUsage.candidatesTokenCount || 0);
if (isFinite(inTok) || isFinite(outTok)) { const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0);
const cachedTok = Number(lastUsage.cachedContentTokenCount || 0);
if (
isFinite(inTok) ||
isFinite(outTok) ||
isFinite(thoughtTok) ||
isFinite(cachedTok)
) {
this.stats.recordTokens( this.stats.recordTokens(
isFinite(inTok) ? inTok : 0, isFinite(inTok) ? inTok : 0,
isFinite(outTok) ? outTok : 0, isFinite(outTok) ? outTok : 0,
isFinite(thoughtTok) ? thoughtTok : 0,
isFinite(cachedTok) ? cachedTok : 0,
); );
// mirror legacy fields for compatibility // mirror legacy fields for compatibility
this.executionStats.inputTokens = this.executionStats.inputTokens =
@@ -453,11 +464,20 @@ export class SubAgentScope {
(isFinite(outTok) ? outTok : 0); (isFinite(outTok) ? outTok : 0);
this.executionStats.totalTokens = this.executionStats.totalTokens =
(this.executionStats.inputTokens || 0) + (this.executionStats.inputTokens || 0) +
(this.executionStats.outputTokens || 0); (this.executionStats.outputTokens || 0) +
(isFinite(thoughtTok) ? thoughtTok : 0) +
(isFinite(cachedTok) ? cachedTok : 0);
this.executionStats.estimatedCost = this.executionStats.estimatedCost =
(this.executionStats.inputTokens || 0) * 3e-5 + (this.executionStats.inputTokens || 0) * 3e-5 +
(this.executionStats.outputTokens || 0) * 6e-5; (this.executionStats.outputTokens || 0) * 6e-5;
} }
this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, {
subagentId: this.subagentId,
round: turnCounter,
usage: lastUsage,
durationMs: Date.now() - roundStreamStart,
timestamp: Date.now(),
} as SubAgentUsageEvent);
} }
if (functionCalls.length > 0) { if (functionCalls.length > 0) {

View File

@@ -23,6 +23,12 @@ export type UiEvent =
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }) | (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); | (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
export {
EVENT_API_ERROR,
EVENT_API_RESPONSE,
EVENT_TOOL_CALL,
} from './constants.js';
export interface ToolCallStats { export interface ToolCallStats {
count: number; count: number;
success: number; success: number;