Summary
PromptResponse.usage semantics are self-contradictory: "for this turn" vs. fields documented "across session" — implementations already diverge
Summary
The usage field on PromptResponse (v1, unstable_end_turn_token_usage) is documented as per-turn, while every field inside the Usage struct it points to is documented as cumulative across the session. Both readings are defensible from the doc comments alone, and shipping agents have already split along that exact line: Copilot CLI reports cumulative session totals, while the reference bridges for Claude Code and Codex report per-turn values.
There is no way for a client to tell which convention an agent follows, and no flag or capability to negotiate it. Getting it wrong silently corrupts token accounting in opposite directions.
The contradiction
In agent-client-protocol-schema/src/v1/agent.rs:
// line 3063 — PromptResponse
/// Token usage for this turn (optional).
#[cfg(feature = "unstable_end_turn_token_usage")]
pub usage: Option<Usage>,
// line 3148 — the struct it points at
/// Token usage information for a prompt turn.
pub struct Usage {
/// Sum of all token types across session. // line 3156
pub total_tokens: u64,
/// Total input tokens across all turns. // line 3158
pub input_tokens: u64,
/// Total output tokens across all turns. // line 3160
pub output_tokens: u64,
/// Total thought/reasoning tokens
pub thought_tokens: Option<u64>,
/// Total cache read tokens.
pub cached_read_tokens: Option<u64>,
/// Total cache write tokens.
pub cached_write_tokens: Option<u64>,
...
}
So the container says "this turn" and the payload says "across session" / "across all turns". These are propagated verbatim into the generated JSON Schema and the TypeScript SDK (@agentclientprotocol/sdk@1.3.0), so implementers on every language binding see the same conflict.
Evidence: three implementations, three behaviors
Same client, same script, same three prompts in a single session, over a raw NDJSON ACP client (no SDK): initialize → session/new → three session/prompt calls.
Reply with exactly: a. Do not use tools.
Write a 150-word paragraph about the color blue. Do not use tools.
Reply with exactly: b. Do not use tools.
The long prompt in the middle is the discriminator: under per-turn semantics outputTokens must drop sharply on turn 3; under cumulative semantics it can only grow.
Claude Code — @agentclientprotocol/claude-agent-acp@0.62.0 (Claude Code 2.1.220): per-turn
| Turn |
inputTokens |
outputTokens |
cachedReadTokens |
cachedWriteTokens |
totalTokens |
| 1 |
2 |
3 |
15743 |
8193 |
23941 |
| 2 |
2 |
1884 |
23936 |
7758 |
33580 |
| 3 |
2 |
3 |
31694 |
2287 |
33986 |
outputTokens collapses from 1884 back to 3 → unambiguously per-turn. Note totalTokens is the sum of that turn's own fields (2 + 3 + 15743 + 8193 = 23941), not a running session total — it merely looks cumulative because cachedReadTokens grows with the conversation.
GitHub Copilot CLI 1.0.78-0 / 1.0.78-1 (native ACP server): cumulative
| Turn |
inputTokens |
outputTokens |
cachedReadTokens |
totalTokens |
| 1 |
14183 |
47 |
6656 |
14230 |
| 2 |
28446 |
894 |
20480 |
29340 |
| 3 |
42970 |
925 |
34304 |
43895 |
Turn 3 is a two-character reply, yet outputTokens rises from 894 to 925 — it is the running session total (894 + ~31). Every field accumulates monotonically.
Codex — @agentclientprotocol/codex-acp@1.0.1 (codex-cli 0.144.6): per-turn by construction
In my environment the field came back null on all three turns (with a proprietary _meta sibling: {"quota":{"token_count":null,"model_usage":[]}}), so I could not measure it end to end. The mapping in the bridge is unambiguous though — it keeps both values Codex emits and forwards only the per-turn one:
handleTokenUsageUpdated(params) {
this.sessionState.lastTokenUsage = toTokenCount(params.tokenUsage.last); // -> PromptResponse.usage
this.sessionState.totalTokenUsage = toTokenCount(params.tokenUsage.total); // kept, not sent as usage
...
}
Agents that report nothing
For completeness, same script: cursor-agent 2026.07.23 and @jetbrains/junie 26.7.20 both return usage: null and emit no usage-bearing notification.
Why this matters for clients
A client aggregating per-turn cost has to pick one interpretation, and the failure modes are severe in both directions:
- Treat it as per-turn (what the
PromptResponse doc says) and sum across a session → Copilot's numbers are double-counted, growing quadratically. In a 10-turn session the reported total is several times the real one.
- Treat it as cumulative and diff against the previous turn → Claude and Codex produce negative deltas (1884 → 3 yields −1881), which either underflows or has to be clamped, silently discarding the largest turn in the session.
There is no capability, no _meta marker, and no version signal to discriminate. The only workaround available today is a hardcoded per-agent table in the client, which is exactly what a standardized field exists to avoid.
Worth noting that cumulative is derivable from per-turn by a stateless client, but per-turn is not recoverable from cumulative when any turn is missed (a dropped notification, a client attaching mid-session, or a session/load on an existing session) — which is an argument for making per-turn the normative wire format.
v2 moves the field but does not settle the question
In v2 the struct is no longer on PromptResponse; it hangs off the idle state update (agent-client-protocol-schema/src/v2/client.rs:545, IdleStateUpdate), and the doc comments were rewritten in the other direction:
agent-client-protocol-schema/src/v2/agent.rs:3273 — "Token usage information for completed session work."
- the field on
IdleStateUpdate — "Token usage for completed foreground work."
input_tokens / output_tokens lost their "across all turns" wording, but total_tokens still reads "Sum of all token types across session".
So v2 leans cumulative without saying so normatively (does "completed session work" mean everything since session/new, or the work that just completed?), and v1 — which is what every agent above actually speaks on the wire today — keeps its internal contradiction. Whatever v2 settles on, v1's docs still need to be made unambiguous, because agents are shipping against them right now.
Request
- Pick one normative semantics for v1
PromptResponse.usage and state it in the PromptResponse field doc: per-turn or cumulative-since-session-start. (Per-turn is the current behavior of both reference bridges, and is the strictly more informative of the two — see above.)
- Fix the
Usage field doc comments to match, so "Sum of all token types across session" / "Total input tokens across all turns" no longer contradict "Token usage for this turn".
- If both are meant to be legal, make it discoverable — e.g. an agent capability or a discriminator on
Usage — rather than leaving clients to maintain a per-agent lookup table.
- Apply the same clarification to the v2
IdleStateUpdate.usage wording, spelling out the accumulation window explicitly.
References
Reproduction client (Node, no dependencies)
// node probe.mjs — set CMD/ARGS to the agent under test, e.g.
// copilot: { cmd: 'copilot', args: ['--acp', '--stdio'] }
// claude: { cmd: process.execPath, args: [require.resolve('@agentclientprotocol/claude-agent-acp/dist/index.js')] }
// codex: { cmd: process.execPath, args: [require.resolve('@agentclientprotocol/codex-acp/dist/index.js')] }
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const { cmd, args } = { cmd: 'copilot', args: ['--acp', '--stdio'] };
const cwd = mkdtempSync(join(tmpdir(), 'acp-'));
const child = spawn(cmd, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
let nextId = 1;
const pending = new Map();
const send = o => child.stdin.write(JSON.stringify(o) + '\n');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ jsonrpc: '2.0', id, method, params });
});
let buf = '';
child.stdout.on('data', chunk => {
buf += chunk.toString();
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
const msg = JSON.parse(line);
if (msg.params?.update?.sessionUpdate === 'usage_update')
console.log(' usage_update:', JSON.stringify(msg.params.update));
if (msg.id !== undefined && msg.method) { send({ jsonrpc: '2.0', id: msg.id, result: {} }); continue; }
const p = pending.get(msg.id);
if (p) { pending.delete(msg.id); msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result); }
}
});
const init = await request('initialize', {
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
});
console.log('agent:', JSON.stringify(init.agentInfo));
const { sessionId } = await request('session/new', { cwd, mcpServers: [] });
for (const text of [
'Reply with exactly: a. Do not use tools.',
'Write a 150-word paragraph about the color blue. Do not use tools.',
'Reply with exactly: b. Do not use tools.',
]) {
const r = await request('session/prompt', { sessionId, prompt: [{ type: 'text', text }] });
console.log('usage:', JSON.stringify(r.usage ?? null));
}
child.kill();
ACP Version
Versions
| Component |
Version |
ACP protocolVersion negotiated |
1 — for every agent below; none negotiated v2 |
| Schema source inspected |
agentclientprotocol/agent-client-protocol @ f2d6f889bf6c (2026-08-03) |
@agentclientprotocol/sdk |
1.3.0 (consulted for the generated types/JSON Schema; the probe client itself uses no SDK) |
| Copilot CLI — native ACP server |
1.0.77 (no usage), 1.0.78-0 and 1.0.78-1 (both report usage) |
@agentclientprotocol/claude-agent-acp — bridge |
0.62.0 (Claude Code 2.1.220) |
@agentclientprotocol/codex-acp — bridge |
1.0.1 (codex-cli 0.144.6) |
cursor-agent — native ACP server |
2026.07.23-e383d2b (sends no agentInfo) |
@jetbrains/junie — native ACP server |
26.7.20 (2383.9) |
| Devin CLI — native ACP server |
3000.3.27 (0becb483) (agentInfo reports affogato / 0.0.0-dev) |
Note: the npm package @github/copilot@1.0.78-0 ships a distinct binary from 1.0.78-1 (different SHA-256) but reports 1.0.78-1 in agentInfo. Both exhibit the cumulative behavior; the meaningful boundary is stable 1.0.77 (no usage) vs. prerelease 1.0.78 (cumulative usage).
Summary
PromptResponse.usagesemantics are self-contradictory: "for this turn" vs. fields documented "across session" — implementations already divergeSummary
The
usagefield onPromptResponse(v1,unstable_end_turn_token_usage) is documented as per-turn, while every field inside theUsagestruct it points to is documented as cumulative across the session. Both readings are defensible from the doc comments alone, and shipping agents have already split along that exact line: Copilot CLI reports cumulative session totals, while the reference bridges for Claude Code and Codex report per-turn values.There is no way for a client to tell which convention an agent follows, and no flag or capability to negotiate it. Getting it wrong silently corrupts token accounting in opposite directions.
The contradiction
In
agent-client-protocol-schema/src/v1/agent.rs:So the container says "this turn" and the payload says "across session" / "across all turns". These are propagated verbatim into the generated JSON Schema and the TypeScript SDK (
@agentclientprotocol/sdk@1.3.0), so implementers on every language binding see the same conflict.Evidence: three implementations, three behaviors
Same client, same script, same three prompts in a single session, over a raw NDJSON ACP client (no SDK):
initialize→session/new→ threesession/promptcalls.Reply with exactly: a. Do not use tools.Write a 150-word paragraph about the color blue. Do not use tools.Reply with exactly: b. Do not use tools.The long prompt in the middle is the discriminator: under per-turn semantics
outputTokensmust drop sharply on turn 3; under cumulative semantics it can only grow.Claude Code —
@agentclientprotocol/claude-agent-acp@0.62.0(Claude Code 2.1.220): per-turninputTokensoutputTokenscachedReadTokenscachedWriteTokenstotalTokensoutputTokenscollapses from 1884 back to 3 → unambiguously per-turn. NotetotalTokensis the sum of that turn's own fields (2 + 3 + 15743 + 8193 = 23941), not a running session total — it merely looks cumulative becausecachedReadTokensgrows with the conversation.GitHub Copilot CLI 1.0.78-0 / 1.0.78-1 (native ACP server): cumulative
inputTokensoutputTokenscachedReadTokenstotalTokensTurn 3 is a two-character reply, yet
outputTokensrises from 894 to 925 — it is the running session total (894 + ~31). Every field accumulates monotonically.Codex —
@agentclientprotocol/codex-acp@1.0.1(codex-cli 0.144.6): per-turn by constructionIn my environment the field came back
nullon all three turns (with a proprietary_metasibling:{"quota":{"token_count":null,"model_usage":[]}}), so I could not measure it end to end. The mapping in the bridge is unambiguous though — it keeps both values Codex emits and forwards only the per-turn one:Agents that report nothing
For completeness, same script:
cursor-agent 2026.07.23and@jetbrains/junie 26.7.20both returnusage: nulland emit no usage-bearing notification.Why this matters for clients
A client aggregating per-turn cost has to pick one interpretation, and the failure modes are severe in both directions:
PromptResponsedoc says) and sum across a session → Copilot's numbers are double-counted, growing quadratically. In a 10-turn session the reported total is several times the real one.There is no capability, no
_metamarker, and no version signal to discriminate. The only workaround available today is a hardcoded per-agent table in the client, which is exactly what a standardized field exists to avoid.Worth noting that cumulative is derivable from per-turn by a stateless client, but per-turn is not recoverable from cumulative when any turn is missed (a dropped notification, a client attaching mid-session, or a
session/loadon an existing session) — which is an argument for making per-turn the normative wire format.v2 moves the field but does not settle the question
In v2 the struct is no longer on
PromptResponse; it hangs off the idle state update (agent-client-protocol-schema/src/v2/client.rs:545,IdleStateUpdate), and the doc comments were rewritten in the other direction:agent-client-protocol-schema/src/v2/agent.rs:3273— "Token usage information for completed session work."IdleStateUpdate— "Token usage for completed foreground work."input_tokens/output_tokenslost their "across all turns" wording, buttotal_tokensstill reads "Sum of all token types across session".So v2 leans cumulative without saying so normatively (does "completed session work" mean everything since
session/new, or the work that just completed?), and v1 — which is what every agent above actually speaks on the wire today — keeps its internal contradiction. Whatever v2 settles on, v1's docs still need to be made unambiguous, because agents are shipping against them right now.Request
PromptResponse.usageand state it in thePromptResponsefield doc: per-turn or cumulative-since-session-start. (Per-turn is the current behavior of both reference bridges, and is the strictly more informative of the two — see above.)Usagefield doc comments to match, so "Sum of all token types across session" / "Total input tokens across all turns" no longer contradict "Token usage for this turn".Usage— rather than leaving clients to maintain a per-agent lookup table.IdleStateUpdate.usagewording, spelling out the accumulation window explicitly.References
PromptResponse.usage(plus ausage_updatenotification withused/size), with cumulative session semantics. The change is not mentioned in the 1.0.78-0 release notes.google-gemini/gemini-cli#24280— Gemini CLI routing token data through_meta.quotainstead ofusage(referenced from Clarify that PromptResponse.usage and UsageUpdate.cost are expected for all servers #890); Codex's bridge exposes a similar_meta.quotasibling.Reproduction client (Node, no dependencies)
ACP Version
Versions
protocolVersionnegotiatedagentclientprotocol/agent-client-protocol@f2d6f889bf6c(2026-08-03)@agentclientprotocol/sdkusage), 1.0.78-0 and 1.0.78-1 (both reportusage)@agentclientprotocol/claude-agent-acp— bridge@agentclientprotocol/codex-acp— bridgecursor-agent— native ACP serveragentInfo)@jetbrains/junie— native ACP serveragentInforeportsaffogato/0.0.0-dev)Note: the npm package
@github/copilot@1.0.78-0ships a distinct binary from1.0.78-1(different SHA-256) but reports1.0.78-1inagentInfo. Both exhibit the cumulative behavior; the meaningful boundary is stable 1.0.77 (nousage) vs. prerelease 1.0.78 (cumulativeusage).