Skip to content

fix(acp): ACP client provider discards token usage from PromptResponse #8132

@salekseev

Description

@salekseev

Problem

When using an ACP backend (claude-acp, codex-acp), Provider::complete() always returns ProviderUsage with all-zero token counts. Token tracking, cost reporting, and context-window monitoring are broken for any ACP-based provider.

Root cause — three-point gap in crates/goose/src/acp/provider.rs

  1. Enum: AcpUpdate::Complete(StopReason) only carries StopReason, no room for usage (provider.rs L118)
  2. Sender: When PromptResponse arrives, only r.stop_reason is forwarded; r.usage is silently dropped (provider.rs L1112)
  3. Receiver: The stream handler breaks out of the loop on Complete without yielding any ProviderUsage (provider.rs L711-712)

Since the stream never yields a non-None usage, collect_stream() in providers/base.rs falls back to ProviderUsage::new("unknown", Usage::default()).

ACP protocol support exists

  • agent-client-protocol-schema v0.10.8 defines PromptResponse.usage: Option<Usage> behind the unstable_session_usage feature
  • Goose enables this via features = ["unstable"] in crates/goose/Cargo.toml
  • The Usage struct has: total_tokens, input_tokens, output_tokens, plus optional thought_tokens, cached_read_tokens, cached_write_tokens

ACP agent adapter status

Agent adapter Populates PromptResponse.usage? Sends UsageUpdate notifications?
claude-agent-acp Yes — accumulates from every Claude API result Yes (context window + cost)
codex-acp (Zed) No — returns bare PromptResponse::new(stop_reason) Yes (context window only)
goose-acp server (crates/goose-acp/src/server.rs) No — returns bare PromptResponse::new(stop_reason)

Note: UsageUpdate (a mid-stream SessionNotification) only carries context-window-level data (used, size, optional cost), not per-turn input/output token breakdown. It cannot substitute for PromptResponse.usage.

Proposed fix — two parts

Part A: ACP client provider (crates/goose/src/acp/provider.rs) — consume usage

Three targeted changes:

1. Extend AcpUpdate::Complete to carry usage:

use agent_client_protocol_schema::Usage as AcpUsage;

enum AcpUpdate {
    // ...existing variants...
    Complete(StopReason, Option<AcpUsage>),
    // ...
}

2. Extract r.usage when sending the complete signal (~L1112):

Ok(r) => {
    let _ = response_tx.try_send(AcpUpdate::Complete(r.stop_reason, r.usage));
}

3. Map ACP Usage to ProviderUsage and yield on the final stream item (~L711):

AcpUpdate::Complete(_reason, acp_usage) => {
    if let Some(u) = acp_usage {
        let provider_usage = ProviderUsage::new(
            model_config.model_name.clone(),
            Usage {
                input_tokens: Some(u.input_tokens as i32),
                output_tokens: Some(u.output_tokens as i32),
                total_tokens: Some(u.total_tokens as i32),
            },
        );
        yield (None, Some(provider_usage));
    }
    break;
}

Part B: ACP server (crates/goose-acp/src/server.rs) — emit usage

The Goose ACP server (when Goose acts as an ACP agent) returns bare PromptResponse::new(stop_reason) without populating .usage(). Since Goose already tracks ProviderUsage internally per agent turn, it should accumulate and return it:

let response = PromptResponse::new(stop_reason)
    .usage(acp_schema::Usage::new(
        total_tokens,
        input_tokens,
        output_tokens,
    ));
Ok(response)

Notes

  • The Usage type from agent-client-protocol-schema uses u64 while Goose's internal Usage uses Option<i32>. The mapping truncates, which is fine for practical token counts but worth noting.
  • AcpUsage also has optional thought_tokens, cached_read_tokens, cached_write_tokens which Goose's Usage does not model. These could be surfaced later.
  • For agents that don't populate PromptResponse.usage (like codex-acp today), the fix degrades gracefully — r.usage is None, no ProviderUsage is yielded, and the existing "unknown" fallback applies. As those adapters add usage reporting, it starts working automatically.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions