Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/acp-report-turn-usage.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions packages/acp-adapter/src/events-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import type {
SessionNotification,
ToolCallContent,
ToolKind,
Usage,
} from '@agentclientprotocol/sdk';
import type {
AssistantDeltaEvent,
SessionUsage,
ThinkingDeltaEvent,
ToolCallDeltaEvent,
ToolCallStartedEvent,
Expand All @@ -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.
Expand Down
45 changes: 43 additions & 2 deletions packages/acp-adapter/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ import {
toolCallStartToSessionUpdate,
toolProgressToSessionUpdate,
toolResultToSessionUpdate,
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';
Expand Down Expand Up @@ -786,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<PromptResponse> {
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,
Expand Down Expand Up @@ -818,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<PromptResponse> {
Expand Down Expand Up @@ -1202,7 +1237,13 @@ export class AcpSession {
this.currentTurnId = undefined;
unsub();
}
resolve({ 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. 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);
}
});

Expand Down
58 changes: 58 additions & 0 deletions packages/acp-adapter/test/session-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function makeInMemoryStreamPair(): {
function makeScriptedSession(
sessionId: string,
script: readonly Event[],
getUsage?: () => Promise<unknown>,
): {
session: Session;
unsubscribeCount: () => number;
Expand All @@ -84,6 +85,7 @@ function makeScriptedSession(
}
},
cancel: async () => undefined,
getUsage,
onEvent: (fn: (event: Event) => void) => {
listeners.add(fn);
return () => {
Expand Down Expand Up @@ -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 },
Expand Down
36 changes: 36 additions & 0 deletions packages/acp-adapter/test/session-slash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function makeInMemoryStreamPair(): {
function makeFakeSession(
sessionId: string,
script: readonly Event[],
getUsage?: () => Promise<unknown>,
): {
session: Session;
calls: {
Expand Down Expand Up @@ -89,6 +90,7 @@ function makeFakeSession(
await emit();
},
cancel: async () => undefined,
getUsage,
onEvent: (fn: (event: Event) => void) => {
listeners.add(fn);
return () => {
Expand Down Expand Up @@ -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)]);
Expand Down