Skip to content

PromptResponse.usage semantics are self-contradictory: "for this turn" vs. fields documented "across session" — implementations already diverge #1860

Description

@douglasjunior

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): initializesession/new → three session/prompt calls.

  1. Reply with exactly: a. Do not use tools.
  2. Write a 150-word paragraph about the color blue. Do not use tools.
  3. 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

  1. 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.)
  2. 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".
  3. 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.
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions