diff --git a/packages/cli/src/acp-integration/schema.ts b/packages/cli/src/acp-integration/schema.ts index 1a486818..ac754318 100644 --- a/packages/cli/src/acp-integration/schema.ts +++ b/packages/cli/src/acp-integration/schema.ts @@ -328,6 +328,7 @@ export type Usage = z.infer; export const sessionUpdateMetaSchema = z.object({ usage: usageSchema.optional().nullable(), + durationMs: z.number().optional().nullable(), }); export type SessionUpdateMeta = z.infer; diff --git a/packages/cli/src/acp-integration/service/filesystem.test.ts b/packages/cli/src/acp-integration/service/filesystem.test.ts new file mode 100644 index 00000000..70ccfc2d --- /dev/null +++ b/packages/cli/src/acp-integration/service/filesystem.test.ts @@ -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', + }); + }); + }); +}); diff --git a/packages/cli/src/acp-integration/service/filesystem.ts b/packages/cli/src/acp-integration/service/filesystem.ts index af7c26ad..7bcaee2d 100644 --- a/packages/cli/src/acp-integration/service/filesystem.ts +++ b/packages/cli/src/acp-integration/service/filesystem.ts @@ -31,10 +31,16 @@ export class AcpFileSystemService implements FileSystemService { }); 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*(?.*)$/i.exec(response.content); const err = new Error(response.content) as NodeJS.ErrnoException; err.code = 'ENOENT'; err.errno = -2; - err.path = filePath; + const rawPath = match?.groups?.['path']?.trim(); + err['path'] = rawPath + ? rawPath.replace(/^['"]|['"]$/g, '') || filePath + : filePath; throw err; } diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 1bd11c79..0ecbccb9 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -4,7 +4,7 @@ * 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, GenerateContentResponseUsageMetadata, @@ -135,6 +135,54 @@ export class HistoryReplayer { // Note: args aren't stored in tool_result records by default 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 { + 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); + } } /** diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index a6466d1e..1d90ed20 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -4,7 +4,12 @@ * 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 { Config, GeminiChat, @@ -195,6 +200,8 @@ export class Session implements SessionContext { } const functionCalls: FunctionCall[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = null; + const streamStartTime = Date.now(); try { const responseStream = await chat.sendMessageStream( @@ -225,22 +232,16 @@ export class Session implements SessionContext { continue; } - const content: acp.ContentBlock = { - type: 'text', - text: part.text, - }; - - this.sendUpdate({ - sessionUpdate: part.thought - ? 'agent_thought_chunk' - : 'agent_message_chunk', - content, - }); + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, + ); } } if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { - this.messageEmitter.emitUsageMetadata(resp.value.usageMetadata); + usageMetadata = resp.value.usageMetadata; } if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) { @@ -258,6 +259,15 @@ export class Session implements SessionContext { throw error; } + if (usageMetadata) { + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, + ); + } + if (functionCalls.length > 0) { const toolResponseParts: Part[] = []; diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index c6c83292..1e745b92 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -9,6 +9,7 @@ import type { SubAgentToolCallEvent, SubAgentToolResultEvent, SubAgentApprovalRequestEvent, + SubAgentUsageEvent, ToolCallConfirmationDetails, AnyDeclarativeTool, AnyToolInvocation, @@ -20,6 +21,7 @@ import { import { z } from 'zod'; import type { SessionContext } from './types.js'; import { ToolCallEmitter } from './emitters/ToolCallEmitter.js'; +import { MessageEmitter } from './emitters/MessageEmitter.js'; import type * as acp from '../acp.js'; /** @@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [ */ export class SubAgentTracker { private readonly toolCallEmitter: ToolCallEmitter; + private readonly messageEmitter: MessageEmitter; private readonly toolStates = new Map< string, { @@ -76,6 +79,7 @@ export class SubAgentTracker { private readonly client: acp.Client, ) { this.toolCallEmitter = new ToolCallEmitter(ctx); + this.messageEmitter = new MessageEmitter(ctx); } /** @@ -92,16 +96,19 @@ export class SubAgentTracker { const onToolCall = this.createToolCallHandler(abortSignal); const onToolResult = this.createToolResultHandler(abortSignal); const onApproval = this.createApprovalHandler(abortSignal); + const onUsageMetadata = this.createUsageMetadataHandler(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); return [ () => { eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); + eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata); // Clean up any remaining states 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. */ diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts index 7720d1f1..d0b1ae87 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.test.ts @@ -175,5 +175,32 @@ describe('MessageEmitter', () => { }, }); }); + + 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, + }, + }); + }); }); }); diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index 00770922..39cdf6a7 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -52,6 +52,7 @@ export class MessageEmitter extends BaseEmitter { async emitUsageMetadata( usageMetadata: GenerateContentResponseUsageMetadata, text: string = '', + durationMs?: number, ): Promise { const usage: Usage = { promptTokens: usageMetadata.promptTokenCount, @@ -61,10 +62,13 @@ export class MessageEmitter extends BaseEmitter { cachedTokens: usageMetadata.cachedContentTokenCount, }; + const meta = + typeof durationMs === 'number' ? { usage, durationMs } : { usage }; + await this.sendUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text }, - _meta: { usage }, + _meta: meta, }); } diff --git a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts index 3ac514c0..9859ed78 100644 --- a/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/ToolCallEmitter.ts @@ -279,10 +279,14 @@ export class ToolCallEmitter extends BaseEmitter { string, unknown >; + const outputField = resp['output']; + const errorField = resp['error']; const responseText = - (resp['output'] as string) ?? - (resp['error'] as string) ?? - JSON.stringify(resp); + typeof outputField === 'string' + ? outputField + : typeof errorField === 'string' + ? errorField + : JSON.stringify(resp); result.push({ type: 'content', content: { type: 'text', text: responseText }, diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index 5560b4fd..17c62a20 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -58,6 +58,7 @@ export type { SubAgentStartEvent, SubAgentRoundEvent, SubAgentStreamTextEvent, + SubAgentUsageEvent, SubAgentToolCallEvent, SubAgentToolResultEvent, SubAgentFinishEvent, diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts index 3c93112d..1f793308 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/subagents/subagent-events.ts @@ -10,7 +10,7 @@ import type { ToolConfirmationOutcome, ToolResultDisplay, } from '../tools/tools.js'; -import type { Part } from '@google/genai'; +import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; export type SubAgentEvent = | 'start' @@ -20,6 +20,7 @@ export type SubAgentEvent = | 'tool_call' | 'tool_result' | 'tool_waiting_approval' + | 'usage_metadata' | 'finish' | 'error'; @@ -31,6 +32,7 @@ export enum SubAgentEventType { TOOL_CALL = 'tool_call', TOOL_RESULT = 'tool_result', TOOL_WAITING_APPROVAL = 'tool_waiting_approval', + USAGE_METADATA = 'usage_metadata', FINISH = 'finish', ERROR = 'error', } @@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent { timestamp: number; } +export interface SubAgentUsageEvent { + subagentId: string; + round: number; + usage: GenerateContentResponseUsageMetadata; + durationMs?: number; + timestamp: number; +} + export interface SubAgentToolCallEvent { subagentId: string; round: number; diff --git a/packages/core/src/subagents/subagent-statistics.test.ts b/packages/core/src/subagents/subagent-statistics.test.ts index 5b4ae3c6..39ba70aa 100644 --- a/packages/core/src/subagents/subagent-statistics.test.ts +++ b/packages/core/src/subagents/subagent-statistics.test.ts @@ -50,6 +50,15 @@ describe('SubagentStatistics', () => { expect(summary.outputTokens).toBe(600); 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', () => { @@ -93,14 +102,14 @@ describe('SubagentStatistics', () => { stats.start(baseTime); stats.setRounds(2); stats.recordToolCall('file_read', true, 100); - stats.recordTokens(1000, 500); + stats.recordTokens(1000, 500, 20, 10); const result = stats.formatCompact('Test task', baseTime + 5000); expect(result).toContain('📋 Task Completed: Test task'); expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success'); 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', () => { diff --git a/packages/core/src/subagents/subagent-statistics.ts b/packages/core/src/subagents/subagent-statistics.ts index 3ef120c6..72308c63 100644 --- a/packages/core/src/subagents/subagent-statistics.ts +++ b/packages/core/src/subagents/subagent-statistics.ts @@ -23,6 +23,8 @@ export interface SubagentStatsSummary { successRate: number; inputTokens: number; outputTokens: number; + thoughtTokens: number; + cachedTokens: number; totalTokens: number; estimatedCost: number; toolUsage: ToolUsageStats[]; @@ -36,6 +38,8 @@ export class SubagentStatistics { private failedToolCalls = 0; private inputTokens = 0; private outputTokens = 0; + private thoughtTokens = 0; + private cachedTokens = 0; private toolUsage = new Map(); start(now = Date.now()) { @@ -74,9 +78,16 @@ export class SubagentStatistics { 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.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 { @@ -86,7 +97,11 @@ export class SubagentStatistics { totalToolCalls > 0 ? (this.successfulToolCalls / totalToolCalls) * 100 : 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; return { rounds: this.rounds, @@ -97,6 +112,8 @@ export class SubagentStatistics { successRate, inputTokens: this.inputTokens, outputTokens: this.outputTokens, + thoughtTokens: this.thoughtTokens, + cachedTokens: this.cachedTokens, totalTokens, estimatedCost, toolUsage: Array.from(this.toolUsage.values()), @@ -116,8 +133,12 @@ export class SubagentStatistics { `⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`, ]; if (typeof stats.totalTokens === 'number') { + const parts = [ + `in ${stats.inputTokens ?? 0}`, + `out ${stats.outputTokens ?? 0}`, + ]; 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'); @@ -152,8 +173,12 @@ export class SubagentStatistics { `🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`, ); if (typeof stats.totalTokens === 'number') { + const parts = [ + `in ${stats.inputTokens ?? 0}`, + `out ${stats.outputTokens ?? 0}`, + ]; 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) { diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index 885e8ca6..39e43e54 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -41,6 +41,7 @@ import type { SubAgentToolResultEvent, SubAgentStreamTextEvent, SubAgentErrorEvent, + SubAgentUsageEvent, } from './subagent-events.js'; import { type SubAgentEventEmitter, @@ -369,6 +370,7 @@ export class SubAgentScope { }, }; + const roundStreamStart = Date.now(); const responseStream = await chat.sendMessageStream( this.modelConfig.model || this.runtimeContext.getModel() || @@ -439,10 +441,19 @@ export class SubAgentScope { if (lastUsage) { const inTok = Number(lastUsage.promptTokenCount || 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( isFinite(inTok) ? inTok : 0, isFinite(outTok) ? outTok : 0, + isFinite(thoughtTok) ? thoughtTok : 0, + isFinite(cachedTok) ? cachedTok : 0, ); // mirror legacy fields for compatibility this.executionStats.inputTokens = @@ -453,11 +464,20 @@ export class SubAgentScope { (isFinite(outTok) ? outTok : 0); this.executionStats.totalTokens = (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.inputTokens || 0) * 3e-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) { diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 9a257e5a..0f8f2146 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -23,6 +23,12 @@ export type UiEvent = | (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }) | (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL }); +export { + EVENT_API_ERROR, + EVENT_API_RESPONSE, + EVENT_TOOL_CALL, +} from './constants.js'; + export interface ToolCallStats { count: number; success: number;