#1129, add usage update in ACP mode

This commit is contained in:
tanzhenxin
2025-12-09 09:58:19 +08:00
parent efbf50554d
commit d7b9466516
15 changed files with 279 additions and 28 deletions

View File

@@ -328,6 +328,7 @@ 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>;

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

@@ -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*(?<path>.*)$/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;
}

View File

@@ -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<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
*/
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[] = [];

View File

@@ -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.
*/

View File

@@ -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,
},
});
});
});
});

View File

@@ -52,6 +52,7 @@ export class MessageEmitter extends BaseEmitter {
async emitUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
): Promise<void> {
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,
});
}

View File

@@ -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 },