Skip to content

Commit 58d3a9c

Browse files
authored
Merge pull request #1176 from QwenLM/feat/acp-usage-metadata
Feat/acp usage metadata
2 parents d06a6d7 + d7b9466 commit 58d3a9c

20 files changed

+503
-35
lines changed

integration-tests/acp-integration.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ type PendingRequest = {
2525
timeout: NodeJS.Timeout;
2626
};
2727

28+
type UsageMetadata = {
29+
promptTokens?: number | null;
30+
completionTokens?: number | null;
31+
thoughtsTokens?: number | null;
32+
totalTokens?: number | null;
33+
cachedTokens?: number | null;
34+
};
35+
2836
type SessionUpdateNotification = {
2937
sessionId?: string;
3038
update?: {
@@ -39,6 +47,9 @@ type SessionUpdateNotification = {
3947
text?: string;
4048
};
4149
modeId?: string;
50+
_meta?: {
51+
usage?: UsageMetadata;
52+
};
4253
};
4354
};
4455

@@ -587,4 +598,52 @@ function setupAcpTest(
587598
await cleanup();
588599
}
589600
});
601+
602+
it('receives usage metadata in agent_message_chunk updates', async () => {
603+
const rig = new TestRig();
604+
rig.setup('acp usage metadata');
605+
606+
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
607+
608+
try {
609+
await sendRequest('initialize', {
610+
protocolVersion: 1,
611+
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
612+
});
613+
await sendRequest('authenticate', { methodId: 'openai' });
614+
615+
const newSession = (await sendRequest('session/new', {
616+
cwd: rig.testDir!,
617+
mcpServers: [],
618+
})) as { sessionId: string };
619+
620+
await sendRequest('session/prompt', {
621+
sessionId: newSession.sessionId,
622+
prompt: [{ type: 'text', text: 'Say "hello".' }],
623+
});
624+
625+
await delay(500);
626+
627+
// Find updates with usage metadata
628+
const updatesWithUsage = sessionUpdates.filter(
629+
(u) =>
630+
u.update?.sessionUpdate === 'agent_message_chunk' &&
631+
u.update?._meta?.usage,
632+
);
633+
634+
expect(updatesWithUsage.length).toBeGreaterThan(0);
635+
636+
const usage = updatesWithUsage[0].update?._meta?.usage;
637+
expect(usage).toBeDefined();
638+
expect(
639+
typeof usage?.promptTokens === 'number' ||
640+
typeof usage?.totalTokens === 'number',
641+
).toBe(true);
642+
} catch (e) {
643+
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
644+
throw e;
645+
} finally {
646+
await cleanup();
647+
}
648+
});
590649
});

packages/cli/src/acp-integration/schema.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,23 @@ export const annotationsSchema = z.object({
316316
priority: z.number().optional().nullable(),
317317
});
318318

319+
export const usageSchema = z.object({
320+
promptTokens: z.number().optional().nullable(),
321+
completionTokens: z.number().optional().nullable(),
322+
thoughtsTokens: z.number().optional().nullable(),
323+
totalTokens: z.number().optional().nullable(),
324+
cachedTokens: z.number().optional().nullable(),
325+
});
326+
327+
export type Usage = z.infer<typeof usageSchema>;
328+
329+
export const sessionUpdateMetaSchema = z.object({
330+
usage: usageSchema.optional().nullable(),
331+
durationMs: z.number().optional().nullable(),
332+
});
333+
334+
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
335+
319336
export const requestPermissionResponseSchema = z.object({
320337
outcome: requestPermissionOutcomeSchema,
321338
});
@@ -500,10 +517,12 @@ export const sessionUpdateSchema = z.union([
500517
z.object({
501518
content: contentBlockSchema,
502519
sessionUpdate: z.literal('agent_message_chunk'),
520+
_meta: sessionUpdateMetaSchema.optional().nullable(),
503521
}),
504522
z.object({
505523
content: contentBlockSchema,
506524
sessionUpdate: z.literal('agent_thought_chunk'),
525+
_meta: sessionUpdateMetaSchema.optional().nullable(),
507526
}),
508527
z.object({
509528
content: z.array(toolCallContentSchema).optional(),
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, expect, it, vi } from 'vitest';
8+
import type { FileSystemService } from '@qwen-code/qwen-code-core';
9+
import { AcpFileSystemService } from './filesystem.js';
10+
11+
const createFallback = (): FileSystemService => ({
12+
readTextFile: vi.fn(),
13+
writeTextFile: vi.fn(),
14+
findFiles: vi.fn().mockReturnValue([]),
15+
});
16+
17+
describe('AcpFileSystemService', () => {
18+
describe('readTextFile ENOENT handling', () => {
19+
it('parses path from ACP ENOENT message (quoted)', async () => {
20+
const client = {
21+
readTextFile: vi
22+
.fn()
23+
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
24+
} as unknown as import('../acp.js').Client;
25+
26+
const svc = new AcpFileSystemService(
27+
client,
28+
'session-1',
29+
{ readTextFile: true, writeTextFile: true },
30+
createFallback(),
31+
);
32+
33+
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
34+
code: 'ENOENT',
35+
path: '/remote/file.txt',
36+
});
37+
});
38+
39+
it('falls back to requested path when none provided', async () => {
40+
const client = {
41+
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
42+
} as unknown as import('../acp.js').Client;
43+
44+
const svc = new AcpFileSystemService(
45+
client,
46+
'session-2',
47+
{ readTextFile: true, writeTextFile: true },
48+
createFallback(),
49+
);
50+
51+
await expect(
52+
svc.readTextFile('/fallback/path.txt'),
53+
).rejects.toMatchObject({
54+
code: 'ENOENT',
55+
path: '/fallback/path.txt',
56+
});
57+
});
58+
});
59+
});

packages/cli/src/acp-integration/service/filesystem.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
3030
limit: null,
3131
});
3232

33+
if (response.content.startsWith('ERROR: ENOENT:')) {
34+
// Treat ACP error strings as structured ENOENT errors without
35+
// assuming a specific platform format.
36+
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
37+
const err = new Error(response.content) as NodeJS.ErrnoException;
38+
err.code = 'ENOENT';
39+
err.errno = -2;
40+
const rawPath = match?.groups?.['path']?.trim();
41+
err['path'] = rawPath
42+
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
43+
: filePath;
44+
throw err;
45+
}
46+
3347
return response.content;
3448
}
3549

packages/cli/src/acp-integration/session/HistoryReplayer.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
411411
]);
412412
});
413413
});
414+
415+
describe('usage metadata replay', () => {
416+
it('should emit usage metadata after assistant message content', async () => {
417+
const record: ChatRecord = {
418+
uuid: 'assistant-uuid',
419+
parentUuid: 'user-uuid',
420+
sessionId: 'test-session',
421+
timestamp: new Date().toISOString(),
422+
type: 'assistant',
423+
cwd: '/test',
424+
version: '1.0.0',
425+
message: {
426+
role: 'model',
427+
parts: [{ text: 'Hello!' }],
428+
},
429+
usageMetadata: {
430+
promptTokenCount: 100,
431+
candidatesTokenCount: 50,
432+
totalTokenCount: 150,
433+
},
434+
};
435+
436+
await replayer.replay([record]);
437+
438+
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
439+
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
440+
sessionUpdate: 'agent_message_chunk',
441+
content: { type: 'text', text: 'Hello!' },
442+
});
443+
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
444+
sessionUpdate: 'agent_message_chunk',
445+
content: { type: 'text', text: '' },
446+
_meta: {
447+
usage: {
448+
promptTokens: 100,
449+
completionTokens: 50,
450+
thoughtsTokens: undefined,
451+
totalTokens: 150,
452+
cachedTokens: undefined,
453+
},
454+
},
455+
});
456+
});
457+
});
414458
});

packages/cli/src/acp-integration/session/HistoryReplayer.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import type { ChatRecord } from '@qwen-code/qwen-code-core';
8-
import type { Content } from '@google/genai';
7+
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
8+
import type {
9+
Content,
10+
GenerateContentResponseUsageMetadata,
11+
} from '@google/genai';
912
import type { SessionContext } from './types.js';
1013
import { MessageEmitter } from './emitters/MessageEmitter.js';
1114
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
@@ -52,6 +55,9 @@ export class HistoryReplayer {
5255
if (record.message) {
5356
await this.replayContent(record.message, 'assistant');
5457
}
58+
if (record.usageMetadata) {
59+
await this.replayUsageMetadata(record.usageMetadata);
60+
}
5561
break;
5662

5763
case 'tool_result':
@@ -88,11 +94,22 @@ export class HistoryReplayer {
8894
toolName: functionName,
8995
callId,
9096
args: part.functionCall.args as Record<string, unknown>,
97+
status: 'in_progress',
9198
});
9299
}
93100
}
94101
}
95102

103+
/**
104+
* Replays usage metadata.
105+
* @param usageMetadata - The usage metadata to replay
106+
*/
107+
private async replayUsageMetadata(
108+
usageMetadata: GenerateContentResponseUsageMetadata,
109+
): Promise<void> {
110+
await this.messageEmitter.emitUsageMetadata(usageMetadata);
111+
}
112+
96113
/**
97114
* Replays a tool result record.
98115
*/
@@ -118,6 +135,54 @@ export class HistoryReplayer {
118135
// Note: args aren't stored in tool_result records by default
119136
args: undefined,
120137
});
138+
139+
// Special handling: Task tool execution summary contains token usage
140+
const { resultDisplay } = result ?? {};
141+
if (
142+
!!resultDisplay &&
143+
typeof resultDisplay === 'object' &&
144+
'type' in resultDisplay &&
145+
(resultDisplay as { type?: unknown }).type === 'task_execution'
146+
) {
147+
await this.emitTaskUsageFromResultDisplay(
148+
resultDisplay as TaskResultDisplay,
149+
);
150+
}
151+
}
152+
153+
/**
154+
* Emits token usage from a TaskResultDisplay execution summary, if present.
155+
*/
156+
private async emitTaskUsageFromResultDisplay(
157+
resultDisplay: TaskResultDisplay,
158+
): Promise<void> {
159+
const summary = resultDisplay.executionSummary;
160+
if (!summary) {
161+
return;
162+
}
163+
164+
const usageMetadata: GenerateContentResponseUsageMetadata = {};
165+
166+
if (Number.isFinite(summary.inputTokens)) {
167+
usageMetadata.promptTokenCount = summary.inputTokens;
168+
}
169+
if (Number.isFinite(summary.outputTokens)) {
170+
usageMetadata.candidatesTokenCount = summary.outputTokens;
171+
}
172+
if (Number.isFinite(summary.thoughtTokens)) {
173+
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
174+
}
175+
if (Number.isFinite(summary.cachedTokens)) {
176+
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
177+
}
178+
if (Number.isFinite(summary.totalTokens)) {
179+
usageMetadata.totalTokenCount = summary.totalTokens;
180+
}
181+
182+
// Only emit if we captured at least one token metric
183+
if (Object.keys(usageMetadata).length > 0) {
184+
await this.messageEmitter.emitUsageMetadata(usageMetadata);
185+
}
121186
}
122187

123188
/**

0 commit comments

Comments
 (0)