From d8a973ef3e2785b229f6de048798bbdd7d381ed7 Mon Sep 17 00:00:00 2001 From: Jetiaime Date: Fri, 17 Jul 2026 23:10:49 +0800 Subject: [PATCH 1/2] feat(acp): report token usage on PromptResponse The ACP adapter left PromptResponse.usage unset, so ACP clients driving kimi (Multica, OpenCode, Claude Code) had zero token/cost visibility even though the SDK tracks usage internally. Populate PromptResponse.usage from session.getUsage() on turn end, mapping the SDK's cumulative total to the ACP Usage shape. Reading usage is best-effort: any failure resolves the prompt with stopReason alone rather than hanging or rejecting. --- .changeset/acp-report-turn-usage.md | 5 ++ packages/acp-adapter/src/events-map.ts | 32 ++++++++++ packages/acp-adapter/src/session.ts | 24 +++++++- .../acp-adapter/test/session-prompt.test.ts | 58 +++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 .changeset/acp-report-turn-usage.md diff --git a/.changeset/acp-report-turn-usage.md b/.changeset/acp-report-turn-usage.md new file mode 100644 index 0000000000..60cd47cefe --- /dev/null +++ b/.changeset/acp-report-turn-usage.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Report token usage over ACP. The ACP adapter now populates `PromptResponse.usage` with cumulative session token counts (input, output, cache read/write, total) so ACP clients and orchestration platforms driving kimi over ACP can read per-turn cost. Reading usage is best-effort and never blocks or fails a completed turn. diff --git a/packages/acp-adapter/src/events-map.ts b/packages/acp-adapter/src/events-map.ts index 0448f2eb9c..a93de8a4aa 100644 --- a/packages/acp-adapter/src/events-map.ts +++ b/packages/acp-adapter/src/events-map.ts @@ -6,9 +6,11 @@ import type { SessionNotification, ToolCallContent, ToolKind, + Usage, } from '@agentclientprotocol/sdk'; import type { AssistantDeltaEvent, + SessionUsage, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -21,6 +23,36 @@ import type { import { displayBlockToAcpContent, toolResultToAcpContent } from './convert'; import type { AcpStopReason } from './types'; +/** + * Map an SDK {@link SessionUsage} to the ACP `Usage` payload carried on + * `PromptResponse.usage`. + * + * The SDK's `total` bucket is cumulative token usage across the session, + * which is exactly what ACP's `Usage` fields document ("across all turns" + * / "across session"). Returns `undefined` when the SDK reports no total — + * the adapter then omits `usage` rather than sending a zeroed payload, + * matching the field's optional contract. + * + * Field mapping (SDK `TokenUsage` → ACP `Usage`): + * - `inputOther` → `inputTokens` + * - `output` → `outputTokens` + * - `inputCacheRead` → `cachedReadTokens` + * - `inputCacheCreation` → `cachedWriteTokens` + * - sum of the four → `totalTokens` + */ +export function sessionUsageToAcpUsage(usage: SessionUsage): Usage | undefined { + const total = usage.total; + if (total === undefined) return undefined; + return { + inputTokens: total.inputOther, + outputTokens: total.output, + cachedReadTokens: total.inputCacheRead, + cachedWriteTokens: total.inputCacheCreation, + totalTokens: + total.inputOther + total.output + total.inputCacheRead + total.inputCacheCreation, + }; +} + /** * Build an ACP `session/update` notification with an * `agent_message_chunk` payload from an SDK `assistant.delta` event. diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 0121bde4e2..9528f752be 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -53,6 +53,7 @@ import { toolCallStartToSessionUpdate, toolProgressToSessionUpdate, toolResultToSessionUpdate, + sessionUsageToAcpUsage, turnEndReasonToStopReason, } from './events-map'; import { acpModeToToggles, DEFAULT_MODE_ID, isAcpModeId, type AcpModeId } from './modes'; @@ -1202,7 +1203,28 @@ export class AcpSession { this.currentTurnId = undefined; unsub(); } - resolve({ stopReason: turnEndReasonToStopReason(event.reason, event.error) }); + const stopReason = turnEndReasonToStopReason(event.reason, event.error); + // Attach cumulative token usage to the ACP `PromptResponse` so + // clients (and orchestration platforms driving kimi over ACP) + // can read per-turn cost. Reading usage is async and best-effort: + // a failure or an unset total must never turn a completed turn + // into a hung or rejected prompt, so we resolve with `stopReason` + // alone on any error. The `Promise.resolve().then(...)` wrapper + // also converts a synchronous throw (e.g. `getUsage` missing on a + // stubbed session) into a caught rejection rather than an + // unhandled one that would leave the prompt pending. + Promise.resolve() + .then(() => this.session.getUsage()) + .then((usage) => { + resolve({ stopReason, usage: sessionUsageToAcpUsage(usage) }); + }) + .catch((err) => { + log.warn('acp: failed to read session usage for prompt response', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + resolve({ stopReason }); + }); } }); diff --git a/packages/acp-adapter/test/session-prompt.test.ts b/packages/acp-adapter/test/session-prompt.test.ts index 048fd57f06..92523470e2 100644 --- a/packages/acp-adapter/test/session-prompt.test.ts +++ b/packages/acp-adapter/test/session-prompt.test.ts @@ -68,6 +68,7 @@ function makeInMemoryStreamPair(): { function makeScriptedSession( sessionId: string, script: readonly Event[], + getUsage?: () => Promise, ): { session: Session; unsubscribeCount: () => number; @@ -84,6 +85,7 @@ function makeScriptedSession( } }, cancel: async () => undefined, + getUsage, onEvent: (fn: (event: Event) => void) => { listeners.add(fn); return () => { @@ -174,6 +176,62 @@ describe('AcpServer session/prompt', () => { expect(unsubscribeCount()).toBe(1); }); + it('reports cumulative token usage on the PromptResponse when the SDK exposes a total', async () => { + const sessionId = 'sess-usage'; + const { session } = makeScriptedSession( + sessionId, + [{ type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'completed' } as Event], + async () => ({ + total: { inputOther: 100, output: 40, inputCacheRead: 10, inputCacheCreation: 5 }, + }), + ); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const response = await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + + expect(response.stopReason).toBe('end_turn'); + expect(response.usage).toEqual({ + inputTokens: 100, + outputTokens: 40, + cachedReadTokens: 10, + cachedWriteTokens: 5, + totalTokens: 155, + }); + }); + + it('resolves without usage when reading session usage rejects', async () => { + const sessionId = 'sess-usage-fail'; + const { session } = makeScriptedSession( + sessionId, + [{ type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'completed' } as Event], + async () => { + throw new Error('usage rpc down'); + }, + ); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const response = await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + + expect(response.stopReason).toBe('end_turn'); + expect(response.usage).toBeUndefined(); + }); + it('rejects prompt with invalid_params when sessionId is unknown', async () => { const harness = { auth: { status: async () => AUTHED_STATUS }, From 6f621b91bf52fc4962214f99df0234df39d14295 Mon Sep 17 00:00:00 2001 From: Jetiaime Date: Fri, 17 Jul 2026 23:36:25 +0800 Subject: [PATCH 2/2] fix(acp): report usage on built-in command prompts too Codex review (P2): the usage read was only wired into runTurnBody, so ACP prompts handled by runBuiltInCommand (notably /compact, which runs a token-consuming compaction) still returned end_turn with no usage. Extract a shared promptResponseWithUsage helper and use it from both the model-turn path and the built-in return path so every terminal prompt reports usage. --- packages/acp-adapter/src/session.ts | 61 ++++++++++++------- .../acp-adapter/test/session-slash.test.ts | 36 +++++++++++ 2 files changed, 76 insertions(+), 21 deletions(-) diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 9528f752be..24332419f2 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -56,6 +56,7 @@ import { sessionUsageToAcpUsage, turnEndReasonToStopReason, } from './events-map'; +import type { AcpStopReason } from './types'; import { acpModeToToggles, DEFAULT_MODE_ID, isAcpModeId, type AcpModeId } from './modes'; import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from './question'; import { detectSlashIntent } from './slash'; @@ -787,6 +788,35 @@ export class AcpSession { return this.runTurnBody(sessionId, conn, () => this.session.prompt(parts)); } + /** + * Build a `PromptResponse` for the given `stopReason`, attaching + * cumulative token usage read best-effort from the SDK. + * + * Reading usage must never turn a finished prompt into a hung or + * rejected one, so any failure (or an unset total, or a session stub + * without `getUsage`) resolves with `stopReason` alone. The + * `Promise.resolve().then(...)` wrapper also converts a synchronous + * throw into a caught rejection rather than an unhandled one. + * + * Used by every terminal prompt path — the model turn in + * `runTurnBody` and the token-consuming built-ins (e.g. `/compact`) in + * `runBuiltInCommand` — so ACP clients see usage regardless of which + * path handled the prompt. + */ + private promptResponseWithUsage(stopReason: AcpStopReason): Promise { + const sessionId = this.id; + return Promise.resolve() + .then(() => this.session.getUsage()) + .then((usage) => ({ stopReason, usage: sessionUsageToAcpUsage(usage) })) + .catch((err) => { + log.warn('acp: failed to read session usage for prompt response', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + return { stopReason }; + }); + } + private async runBuiltInCommand( name: AcpBuiltinSlashCommandName, args: string, @@ -819,7 +849,11 @@ export class AcpSession { } catch (error) { await this.emitLocalCommandMessage(`/${name} failed: ${errorMessage(error)}`); } - return { stopReason: 'end_turn' }; + // `/compact` runs a model compaction that consumes tokens, so this + // return path must report usage too (not just the model-turn path in + // runTurnBody). The other built-ins are local and consume none, but + // the read is best-effort and harmless for them. + return this.promptResponseWithUsage('end_turn'); } private async runUnknownSlashCommand(name: string): Promise { @@ -1203,28 +1237,13 @@ export class AcpSession { this.currentTurnId = undefined; unsub(); } - const stopReason = turnEndReasonToStopReason(event.reason, event.error); // Attach cumulative token usage to the ACP `PromptResponse` so // clients (and orchestration platforms driving kimi over ACP) - // can read per-turn cost. Reading usage is async and best-effort: - // a failure or an unset total must never turn a completed turn - // into a hung or rejected prompt, so we resolve with `stopReason` - // alone on any error. The `Promise.resolve().then(...)` wrapper - // also converts a synchronous throw (e.g. `getUsage` missing on a - // stubbed session) into a caught rejection rather than an - // unhandled one that would leave the prompt pending. - Promise.resolve() - .then(() => this.session.getUsage()) - .then((usage) => { - resolve({ stopReason, usage: sessionUsageToAcpUsage(usage) }); - }) - .catch((err) => { - log.warn('acp: failed to read session usage for prompt response', { - sessionId, - error: err instanceof Error ? err.message : String(err), - }); - resolve({ stopReason }); - }); + // can read per-turn cost. Best-effort via the shared helper: + // reading usage never turns a finished turn into a hung or + // rejected prompt. + const stopReason = turnEndReasonToStopReason(event.reason, event.error); + void this.promptResponseWithUsage(stopReason).then(resolve); } }); diff --git a/packages/acp-adapter/test/session-slash.test.ts b/packages/acp-adapter/test/session-slash.test.ts index f13dea0b1d..411d6ed489 100644 --- a/packages/acp-adapter/test/session-slash.test.ts +++ b/packages/acp-adapter/test/session-slash.test.ts @@ -60,6 +60,7 @@ function makeInMemoryStreamPair(): { function makeFakeSession( sessionId: string, script: readonly Event[], + getUsage?: () => Promise, ): { session: Session; calls: { @@ -89,6 +90,7 @@ function makeFakeSession( await emit(); }, cancel: async () => undefined, + getUsage, onEvent: (fn: (event: Event) => void) => { listeners.add(fn); return () => { @@ -331,6 +333,40 @@ describe('AcpSession slash routing', () => { expect(text).toContain('/help'); }); + it('reports token usage on a built-in command PromptResponse (covers the /compact path)', async () => { + // The token-consuming built-in is `/compact`, but every built-in + // shares the same return path in `runBuiltInCommand`; exercising + // `/help` with a getUsage stub proves that path now carries usage, + // which is what a token-consuming compaction needs. + const sessionId = 'sess-slash-usage'; + const { session } = makeFakeSession(sessionId, [endedTurn(sessionId)], async () => ({ + total: { inputOther: 200, output: 30, inputCacheRead: 0, inputCacheCreation: 0 }, + })); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await waitForAvailableCommands(collecting); + + const response = await client.prompt({ sessionId, prompt: [textBlock('/help')] }); + + expect(response.stopReason).toBe('end_turn'); + expect(response.usage).toEqual({ + inputTokens: 200, + outputTokens: 30, + cachedReadTokens: 0, + cachedWriteTokens: 0, + totalTokens: 230, + }); + }); + it('routes built-in `/status` locally and renders SDK status fields', async () => { const sessionId = 'sess-slash-status'; const { session, calls } = makeFakeSession(sessionId, [endedTurn(sessionId)]);