From 920105787c46109bfa1f10d809a1baa3d0f4f524 Mon Sep 17 00:00:00 2001 From: WorkerBeeGPT Date: Wed, 5 Aug 2026 14:53:34 -0500 Subject: [PATCH] feat(acp): report standard adapter usage Co-authored-by: Atish Patel Signed-off-by: Atish Patel --- crates/buzz-acp/src/acp.rs | 217 ++++++++++++++++++++++++++++++++--- crates/buzz-acp/src/pool.rs | 133 +++++++++++++++++---- crates/buzz-acp/src/usage.rs | 114 ++++++++++++++++++ 3 files changed, 426 insertions(+), 38 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 700d5e8dcf..f25d6e53bb 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -14,7 +14,9 @@ use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; use crate::observer::{ObserverContext, ObserverHandle}; -use crate::usage::{TurnUsage, UsageTracker}; +use crate::usage::{ + PromptResponseUsage, StandardAdapterKind, StandardUsageTracker, TurnUsage, UsageTracker, +}; /// Maximum allowed size of a single NDJSON line from the agent's stdout. /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. @@ -206,11 +208,12 @@ pub struct AcpClient { /// outside of a goose-native turn — the read loop's steer arm is /// disabled in that case. steer_rx: Option>, - /// Usage tracker — accumulates cumulative token counts from - /// `_goose/unstable/session/update` notifications and computes per-turn - /// deltas. Both goose and buzz-agent emit this notification; goose gates - /// on client capability advertisement, buzz-agent emits unconditionally. + /// Usage tracker for goose/buzz-agent's cumulative notification format. goose_usage: UsageTracker, + /// Per-turn prompt-response usage and Claude's optional cumulative cost. + standard_usage: StandardUsageTracker, + /// Known adapter identity for prompt-response usage mapping. + standard_adapter: Option, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -523,6 +526,14 @@ impl AcpClient { // console-subsystem child process spawned from a GUI/non-console parent. configure_no_window(&mut cmd); + let standard_adapter = + match crate::config::normalize_agent_command_identity(command).as_str() { + "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" => { + Some(StandardAdapterKind::Claude) + } + "codex" | "codex-acp" => Some(StandardAdapterKind::Codex), + _ => None, + }; let mut child = cmd.spawn()?; let stdin = child @@ -550,6 +561,8 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + standard_usage: StandardUsageTracker::default(), + standard_adapter, }) } @@ -776,6 +789,7 @@ impl AcpClient { // prompt so that any setup notifications recorded earlier are not // misattributed to this turn. self.goose_usage.begin_turn(session_id); + self.standard_usage.begin_turn(session_id); self.last_prompt_id = Some(self.next_id); let id = self.next_id; @@ -821,7 +835,7 @@ impl AcpClient { self.current_hard_deadline = None; } } - self.parse_stop_reason(&result?) + self.parse_prompt_response(session_id, &result?) } /// Send a `session/cancel` **notification** (no `id` field, no response expected). @@ -867,18 +881,13 @@ impl AcpClient { self.steering_supported } - /// Consume and return the per-turn usage record computed from the most - /// recent `_goose/unstable/session/update` notification. - /// - /// Returns `None` if no usage update arrived since the last call (i.e. - /// the harness did not emit one for this turn, or this is not a goose - /// agent). Must be called at most once per turn; subsequent calls return - /// `None` until the next `usage_update` notification is recorded. - /// - /// Intended for consumption by `publish_agent_turn_metric` in `pool.rs` to - /// publish a kind 44200 NIP-AM event. + /// Consume per-turn usage for NIP-AM publishing. Goose/buzz-agent is an + /// exclusive cumulative path; standard ACP prompt usage is used only when + /// goose emitted nothing for this turn. pub fn take_turn_usage(&mut self) -> Option { - self.goose_usage.take() + let goose_usage = self.goose_usage.take(); + let standard_usage = self.standard_usage.take(); + goose_usage.or(standard_usage) } /// Install a per-turn steer request channel for goose-native @@ -1038,7 +1047,7 @@ impl AcpClient { remaining, ) .await?; - self.parse_stop_reason(&result) + self.parse_prompt_response(session_id, &result) } /// Serialize `value` as a single NDJSON line and flush to the agent's stdin. @@ -1817,6 +1826,10 @@ impl AcpClient { } false } + "usage_update" => { + self.handle_standard_usage_update(msg); + false + } "keepalive" => false, other => { tracing::debug!(target: "acp::update", "session/update: {other}"); @@ -1825,6 +1838,30 @@ impl AcpClient { } } + /// Record the standard ACP cumulative cost notification when emitted by + /// Claude. Unlike Goose's payload, `used`/`size` are context occupancy and + /// are intentionally not mapped to token accounting. + fn handle_standard_usage_update(&mut self, msg: &serde_json::Value) { + if self.standard_adapter != Some(StandardAdapterKind::Claude) { + return; + } + let session_id = match msg + .pointer("/params/sessionId") + .and_then(serde_json::Value::as_str) + { + Some(session_id) => session_id, + None => return, + }; + let cost = match msg + .pointer("/params/update/cost/amount") + .and_then(serde_json::Value::as_f64) + { + Some(cost) => cost, + None => return, + }; + self.standard_usage.record_cost(session_id, cost); + } + /// Parse a `_goose/unstable/session/update` notification and record the /// usage snapshot in the per-session tracker. /// @@ -1956,6 +1993,28 @@ impl AcpClient { Ok(()) } + /// Parse a completed prompt response and retain its optional per-turn usage. + fn parse_prompt_response( + &mut self, + session_id: &str, + result: &serde_json::Value, + ) -> Result { + let stop_reason = self.parse_stop_reason(result)?; + if let Some(adapter) = self.standard_adapter { + match serde_json::from_value::(result["usage"].clone()) { + Ok(usage) => self + .standard_usage + .record_prompt_usage(session_id, usage, adapter), + Err(_) if result.get("usage").is_some() => tracing::debug!( + target: "acp::usage", + "session/prompt response contained malformed standard usage" + ), + Err(_) => {} + } + } + Ok(stop_reason) + } + /// Parse `stopReason` from a `session/prompt` result value. fn parse_stop_reason(&self, result: &serde_json::Value) -> Result { let raw = result["stopReason"].as_str().ok_or_else(|| { @@ -4261,6 +4320,128 @@ mod tests { } } + // ── Standard ACP prompt-response usage ───────────────────────────────── + + fn prompt_response_usage( + input: u64, + output: u64, + total: u64, + cached_read: Option, + cached_write: Option, + ) -> serde_json::Value { + let mut usage = serde_json::json!({ + "inputTokens": input, + "outputTokens": output, + "totalTokens": total, + }); + if let Some(cached_read) = cached_read { + usage["cachedReadTokens"] = serde_json::json!(cached_read); + } + if let Some(cached_write) = cached_write { + usage["cachedWriteTokens"] = serde_json::json!(cached_write); + } + serde_json::json!({"stopReason": "end_turn", "usage": usage}) + } + + fn standard_cost_update(session_id: &str, cost: f64) -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "usage_update", + "cost": {"amount": cost, "currency": "USD"} + } + } + }) + } + + #[tokio::test] + async fn claude_prompt_response_usage_merges_with_cumulative_cost() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.standard_usage.begin_turn("claude-session"); + client.handle_session_update(&standard_cost_update("claude-session", 0.042)); + assert_eq!( + client + .parse_prompt_response( + "claude-session", + &prompt_response_usage(100, 20, 175, Some(30), Some(25)), + ) + .unwrap(), + StopReason::EndTurn + ); + + let usage = client.take_turn_usage().expect("prompt usage"); + assert!(usage.delta_reliable, "response tokens need no baseline"); + assert_eq!(usage.turn_input_tokens, Some(100)); + assert_eq!(usage.turn_output_tokens, Some(20)); + assert_eq!( + usage.turn_total_tokens, None, + "Claude total is adapter-derived" + ); + assert_eq!(usage.turn_cache_read_tokens, Some(30)); + assert_eq!(usage.turn_cache_write_tokens, Some(25)); + assert_eq!(usage.turn_cost_usd, None, "cost remains cumulative"); + assert_eq!(usage.cumulative_cost_usd, Some(0.042)); + assert!(usage.has_cumulative_usage); + assert!(!usage.cumulative_tokens_present); + } + + #[tokio::test] + async fn codex_prompt_response_usage_preserves_provider_total_without_cost() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Codex); + client.standard_usage.begin_turn("codex-session"); + client.handle_session_update(&standard_cost_update("codex-session", 0.042)); + client + .parse_prompt_response( + "codex-session", + &prompt_response_usage(90, 10, 140, Some(40), None), + ) + .unwrap(); + + let usage = client.take_turn_usage().expect("prompt usage"); + assert!(usage.delta_reliable); + assert_eq!(usage.turn_input_tokens, Some(90)); + assert_eq!(usage.turn_output_tokens, Some(10)); + assert_eq!(usage.turn_total_tokens, Some(140)); + assert_eq!(usage.turn_cache_read_tokens, Some(40)); + assert_eq!(usage.turn_cache_write_tokens, None); + assert_eq!( + usage.cumulative_cost_usd, None, + "Codex cost update is ignored" + ); + assert!(!usage.has_cumulative_usage); + } + + #[tokio::test] + async fn goose_usage_stays_exclusive_and_drains_standard_usage() { + let mut client = spawn_inert_client().await; + client.standard_adapter = Some(StandardAdapterKind::Claude); + client.goose_usage.begin_turn("goose-session"); + client.standard_usage.begin_turn("goose-session"); + client.handle_goose_usage_update(&goose_usage_update_msg("goose-session", 1000, 200, None)); + client + .parse_prompt_response( + "goose-session", + &prompt_response_usage(100, 20, 120, None, None), + ) + .unwrap(); + + let usage = client.take_turn_usage().expect("goose usage"); + assert_eq!(usage.cumulative_input_tokens, 1000); + assert_eq!( + usage.turn_input_tokens, None, + "goose first delta remains exclusive" + ); + assert!( + client.take_turn_usage().is_none(), + "standard usage was drained" + ); + } + // ── Goose usage notification integration ────────────────────────────── /// Build a `_goose/unstable/session/update` JSON-RPC notification. diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index ddc0330d9f..8746d5c358 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3658,10 +3658,8 @@ fn acp_stop_to_core(r: &StopReason) -> buzz_core::agent_turn_metric::StopReason /// /// - `turn` is `None` when `delta_reliable` is false; otherwise it carries the /// per-turn i/o/total/cost deltas for this turn. -/// - `cumulative` always carries the session-aggregate i/o/cost totals. -/// `total_tokens` is `Some` only when the session accumulated a genuine -/// provider-reported total on every turn — never derived from i/o sums -/// (NIP-AM MUST NOT). +/// - `cumulative` is omitted when this harness has no cumulative counters; +/// otherwise it carries the session-aggregate values it reported. pub(crate) fn build_turn_metric_counts( usage: &crate::usage::TurnUsage, ) -> ( @@ -3682,9 +3680,7 @@ pub(crate) fn build_turn_metric_counts( // Field-local: present when the cumulative counter was monotonic // across this turn. Zero means no cache hits this turn (not absent). cache_read_tokens: usage.turn_cache_read_tokens, - // buzz-agent does not emit a cache-write count on the wire today; - // leave None rather than deriving it from other fields. - cache_write_tokens: None, + cache_write_tokens: usage.turn_cache_write_tokens, }) } else { // Defense-in-depth: UsageTracker already sets all turn_* fields to None @@ -3693,22 +3689,17 @@ pub(crate) fn build_turn_metric_counts( // accidentally publishing unreliable per-turn counts. None }; - let cumulative_counts = Some(TokenCounts { - input_tokens: Some(usage.cumulative_input_tokens), - output_tokens: Some(usage.cumulative_output_tokens), - // Present when every turn in the session reported a genuine provider - // total. None when the session has never emitted one or any turn lacked - // one. Never derived from input+output (NIP-AM MUST NOT). + let cumulative_counts = usage.has_cumulative_usage.then_some(TokenCounts { + input_tokens: usage + .cumulative_tokens_present + .then_some(usage.cumulative_input_tokens), + output_tokens: usage + .cumulative_tokens_present + .then_some(usage.cumulative_output_tokens), total_tokens: usage.cumulative_total_tokens, cost_usd: usage.cumulative_cost_usd, - // Session-cumulative cache-read tokens; None when the harness never - // reported this field (e.g. goose or older buzz-agent sessions). - // Passes through directly — do not wrap in Some() as the field already - // carries provenance (None vs Some(0) are distinct meanings). cache_read_tokens: usage.cumulative_cache_read_tokens, - // buzz-agent does not emit a cache-write count on the wire today; - // leave None rather than deriving it from other fields. - cache_write_tokens: None, + cache_write_tokens: usage.cumulative_cache_write_tokens, }); (turn_counts, cumulative_counts) } @@ -6162,12 +6153,16 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: 100, cumulative_output_tokens: 50, cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + has_cumulative_usage: true, }; // owner_pubkey = None → early return, no panic. publish_agent_turn_metric( @@ -6198,12 +6193,16 @@ mod tests { turn_total_tokens: None, turn_cost_usd: Some(0.001), turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: 200, cumulative_output_tokens: 80, cumulative_total_tokens: None, cumulative_cost_usd: Some(0.001), cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + has_cumulative_usage: true, }; // Will try to publish and fail (no real relay) but must not panic. publish_agent_turn_metric( @@ -6235,12 +6234,16 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: 150, cumulative_output_tokens: 70, cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + has_cumulative_usage: true, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. publish_agent_turn_metric( @@ -6272,12 +6275,16 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: 400, cumulative_output_tokens: 100, cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + has_cumulative_usage: true, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. publish_agent_turn_metric( @@ -6306,12 +6313,16 @@ mod tests { turn_total_tokens: Some(130), // genuine per-turn total turn_cost_usd: None, turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: 500, cumulative_output_tokens: 120, cumulative_total_tokens: Some(620), // genuine cumulative total cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + has_cumulative_usage: true, }; let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); @@ -6355,12 +6366,16 @@ mod tests { turn_total_tokens: None, // provider did not supply a total turn_cost_usd: None, turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: 200, cumulative_output_tokens: 60, cumulative_total_tokens: None, // session has no total cumulative_cost_usd: None, cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, model: None, + has_cumulative_usage: true, }; let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); @@ -6399,6 +6414,84 @@ mod tests { ); } + #[test] + fn test_build_turn_metric_counts_claude_tokens_and_cumulative_cost() { + let usage = crate::usage::TurnUsage { + session_id: "claude-session".to_string(), + turn_seq: 3, + delta_reliable: true, + turn_input_tokens: Some(100), + turn_output_tokens: Some(20), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: Some(30), + turn_cache_write_tokens: Some(25), + cumulative_tokens_present: false, + cumulative_input_tokens: 0, + cumulative_output_tokens: 0, + cumulative_total_tokens: None, + cumulative_cost_usd: Some(0.042), + cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, + model: None, + has_cumulative_usage: true, + }; + + let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); + let turn_json = serde_json::to_value(turn.expect("Claude turn counts")).unwrap(); + let cumulative_json = + serde_json::to_value(cumulative.expect("Claude cumulative cost")).unwrap(); + + assert_eq!(turn_json["inputTokens"], serde_json::json!(100)); + assert_eq!(turn_json["outputTokens"], serde_json::json!(20)); + assert!(turn_json["totalTokens"].is_null()); + assert!(turn_json["costUsd"].is_null()); + assert_eq!(turn_json["cacheReadTokens"], serde_json::json!(30)); + assert_eq!(turn_json["cacheWriteTokens"], serde_json::json!(25)); + assert!(cumulative_json["inputTokens"].is_null()); + assert!(cumulative_json["outputTokens"].is_null()); + assert!(cumulative_json["totalTokens"].is_null()); + assert_eq!(cumulative_json["costUsd"], serde_json::json!(0.042)); + } + + #[test] + fn test_build_turn_metric_counts_codex_tokens_without_cumulative() { + let usage = crate::usage::TurnUsage { + session_id: "codex-session".to_string(), + turn_seq: 3, + delta_reliable: true, + turn_input_tokens: Some(90), + turn_output_tokens: Some(10), + turn_total_tokens: Some(140), + turn_cost_usd: None, + turn_cache_read_tokens: Some(40), + turn_cache_write_tokens: None, + cumulative_tokens_present: false, + cumulative_input_tokens: 0, + cumulative_output_tokens: 0, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, + model: None, + has_cumulative_usage: false, + }; + + let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); + let turn_json = serde_json::to_value(turn.expect("Codex turn counts")).unwrap(); + + assert_eq!(turn_json["inputTokens"], serde_json::json!(90)); + assert_eq!(turn_json["outputTokens"], serde_json::json!(10)); + assert_eq!(turn_json["totalTokens"], serde_json::json!(140)); + assert!(turn_json["costUsd"].is_null()); + assert_eq!(turn_json["cacheReadTokens"], serde_json::json!(40)); + assert!(turn_json.get("cacheWriteTokens").is_none()); + assert!( + cumulative.is_none(), + "Codex does not report cumulative usage" + ); + } + /// A payload with nonzero `accumulatedCachedInputTokens` on the second turn /// must produce a kind:44200 payload where `cumulative.cacheReadTokens` is /// nonzero and `turn.cacheReadTokens` reflects the per-turn delta. diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 56b772d12c..a3347097df 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -167,6 +167,10 @@ pub struct TurnUsage { /// a decrease here never flips `delta_reliable` or invalidates the /// input/output deltas. pub turn_cache_read_tokens: Option, + /// Per-turn cache-write token delta; `None` when unreported. + pub turn_cache_write_tokens: Option, + /// Whether cumulative token counters are available for this record. + pub cumulative_tokens_present: bool, /// Session-cumulative input tokens as reported by goose at end of turn. pub cumulative_input_tokens: u64, /// Session-cumulative output tokens as reported by goose at end of turn. @@ -181,9 +185,107 @@ pub struct TurnUsage { /// any harness that omits `accumulatedCachedInputTokens`). /// `Some(0)` when the harness reported zero cache hits. pub cumulative_cache_read_tokens: Option, + /// Session-cumulative cache-write tokens as reported by buzz-agent. + pub cumulative_cache_write_tokens: Option, /// Effective model id for this turn (maps to NIP-AM `model`). `None` if the /// harness did not include the model in its usage notification. pub model: Option, + /// Whether at least one cumulative counter is available for this record. + /// Standard ACP prompt usage is per-turn; only Claude's cumulative cost + /// notification makes its record cumulative. + pub has_cumulative_usage: bool, +} + +/// Per-turn usage carried by the experimental ACP `session/prompt` response. +/// Claude and Codex both scope these fields to the completed prompt. Their +/// input counts exclude cache-served input; preserve that adapter provenance +/// rather than silently normalizing it. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PromptResponseUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub cached_read_tokens: Option, + pub cached_write_tokens: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StandardAdapterKind { + Claude, + Codex, +} + +#[derive(Debug, Default)] +pub(crate) struct StandardUsageTracker { + sessions: HashMap, + in_flight_session: Option, + pending_cost: Option, + pending_prompt: Option<(String, PromptResponseUsage, StandardAdapterKind)>, +} + +impl StandardUsageTracker { + pub(crate) fn begin_turn(&mut self, session_id: &str) { + self.in_flight_session = Some(session_id.to_string()); + self.pending_cost = None; + self.pending_prompt = None; + } + + /// Claude's `usage_update.cost.amount` is a raw session-cumulative total. + /// It belongs in NIP-AM `cumulative`, not a client-computed turn delta. + pub(crate) fn record_cost(&mut self, session_id: &str, cost: f64) { + if cost.is_finite() && cost >= 0.0 && self.in_flight_session.as_deref() == Some(session_id) + { + self.pending_cost = Some(cost); + } + } + + pub(crate) fn record_prompt_usage( + &mut self, + session_id: &str, + usage: PromptResponseUsage, + adapter: StandardAdapterKind, + ) { + if self.in_flight_session.as_deref() == Some(session_id) { + self.pending_prompt = Some((session_id.to_string(), usage, adapter)); + } + } + + pub(crate) fn take(&mut self) -> Option { + self.in_flight_session = None; + let cost = self.pending_cost.take(); + let (session_id, usage, adapter) = self.pending_prompt.take()?; + let turn_seq = { + let seq = self.sessions.entry(session_id.clone()).or_default(); + *seq += 1; + *seq + }; + Some(TurnUsage { + session_id, + turn_seq, + // PromptResponse.usage is already per-turn; no cumulative baseline + // is required for these token counts. + delta_reliable: true, + turn_input_tokens: Some(usage.input_tokens), + turn_output_tokens: Some(usage.output_tokens), + // Claude computes this value by adding categories, whereas Codex + // forwards provider-shaped tokenUsage.last.totalTokens. + turn_total_tokens: (adapter == StandardAdapterKind::Codex) + .then_some(usage.total_tokens), + turn_cost_usd: None, + turn_cache_read_tokens: usage.cached_read_tokens, + turn_cache_write_tokens: usage.cached_write_tokens, + cumulative_tokens_present: false, + cumulative_input_tokens: 0, + cumulative_output_tokens: 0, + cumulative_total_tokens: None, + cumulative_cost_usd: cost, + cumulative_cache_read_tokens: None, + cumulative_cache_write_tokens: None, + model: None, + has_cumulative_usage: cost.is_some(), + }) + } } /// Tracks per-session cumulative usage state across turns. @@ -343,12 +445,16 @@ impl UsageTracker { turn_total_tokens: turn_total, turn_cost_usd: turn_cost, turn_cache_read_tokens: turn_cache_read, + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, cumulative_total_tokens: current_total, cumulative_cost_usd: current_cost, cumulative_cache_read_tokens: current_cached_input, + cumulative_cache_write_tokens: None, model: payload.model.clone(), + has_cumulative_usage: true, }); } else if self.in_flight_session.is_none() { // Not in-flight at all: advance the committed baseline so the next @@ -1449,12 +1555,16 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: None, + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: 700, cumulative_output_tokens: 200, cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: None, // harness did not report the field + cumulative_cache_write_tokens: None, model: None, + has_cumulative_usage: true, }; let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); @@ -1487,12 +1597,16 @@ mod tests { turn_total_tokens: None, turn_cost_usd: None, turn_cache_read_tokens: Some(300), + turn_cache_write_tokens: None, + cumulative_tokens_present: true, cumulative_input_tokens: 700, cumulative_output_tokens: 200, cumulative_total_tokens: None, cumulative_cost_usd: None, cumulative_cache_read_tokens: Some(600), + cumulative_cache_write_tokens: None, model: None, + has_cumulative_usage: true, }; let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage);