Files
buzz/crates/buzz-agent/src/wire.rs
Will Pfleger 5e4c05f90b feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 (#4000)
## What

Implements Phases 2 and 4a of the Usage v2 plan (plan events
`d0268cd0`/`0e95b035`), extending the archive backend to emit,
transport, archive, and aggregate both cache categories and billing
identity fail-closed.

### P2 — emission, transport, archive

**Tri-state accumulators** (`Unseen`/`Exact`/`Unknown`) for cache-read
and cache-write in `buzz-agent` turn and session state. Absent field =
Unknown (never zero) through the full pipeline. No `unwrap_or(0)` on the
cache path. Both cache folds are gated on usage-bearing responses (same
gate as the total-state and identity folds) — a response with no usage
at all must not poison either accumulator.

**Overflow-aware input token parsing and accumulation** — closed
end-to-end from parse through wire to ACP:
- `sum_usage()` returns `SumUsageResult` (`Exact(u64)` | `Overflow`) —
checked arithmetic, never clamps. `anthropic_input_tokens()` returns
`Option<SumUsageResult>` since it sums three fields (`input_tokens +
cache_read_input_tokens + cache_creation_input_tokens`) that can
collectively overflow. Single-field callers (`prompt_tokens`,
`completion_tokens`, etc.) convert via `.into_exact()` — their
single-field sums cannot overflow.
- `LlmResponse.input_tokens_overflowed: bool` propagates the parse-layer
signal into the run loop. When set, `input_tokens` is `None` (clamped
value discarded), the context-gate baseline
(`last_request_input_tokens`) is frozen at its prior reading, and
`turn_input_tokens` is poisoned to `TurnIOState::Poisoned` before any
emission — including mid-turn `emit_usage_update` calls. A dedicated
enum on `LlmResponse.input_tokens` would ripple into ~20 existing test
assertions on `r.input_tokens == Some(...)`; the bool flag confines the
change to the two call sites that check it.
- `TurnIOState` (`Unseen`/`Exact`/`Poisoned`) for input and output:
per-round fold uses `checked_add`; overflow poisons permanently at turn
and session level, no healing. Absence does not poison (pass-2-cleared
contract unchanged). Wire emission omits
`accumulatedInputTokens`/`accumulatedOutputTokens` when poisoned — never
null, never `u64::MAX`. ACP treats absent = publisher-poisoned:
`delta_reliable: false`, null turn fields, null cumulative for that
category; session cumulative stays unknown for all subsequent turns once
poisoned.

**Conditional wire emission** for `accumulatedCachedInputTokens` and new
`accumulatedCacheWriteTokens`: fields are omitted when the cumulative is
Unseen or Unknown. ACP `_goose/unstable/session/update` contract
documented next to the payload with tests for all absence/zero variants.

**`PricingIdentity` stamping (publisher-side)**:
- `pricing_authority()`: canonical parsed-URL endpoint comparison
against the official allowlist — HTTPS only, exact allowlisted host
(lookalike-safe), default port (omitted or explicit :443), required API
base path, rejects userinfo/query/fragment/path-prefix lookalikes.
- Model: the actually-requested `request_model` after mesh/auto
resolution (not `effective_model_str`).
- Turn discipline: identity retained only while ALL usage in the current
turn carries one identical proven identity; any mismatch,
unproven-usage-bearing response, or unpaired cumulative snapshot poisons
to absent; a later matching notification does not heal a mixed turn.

**ACP `UsageTracker` identity fold**: per-in-flight-turn tri-state
identity accumulator replacing last-update-wins. Any absent identity on
a token-advancing notification or exact mismatch poisons to absent;
poison survives later updates; reset in `begin_turn()`/`take()`; reset
also when a request fails (baseline cleared so preflight gate cannot
stay frozen sub-threshold on retries).

**M3 migration**: adds `turn_cache_write_tokens`,
`cumulative_cache_write_tokens`, `pricing_authority`, `pricing_model`,
`pricing_cache_class` to `agent_metric_index`. Additive, idempotent,
guarded per-column by marker. M2 migration also guarded per-column (turn
and cumulative cache-read columns checked and added independently;
marker commits only after both are present). Fresh-DB schema includes
all columns.

**First-turn baselines**: `seed_zero_baseline` seeds `last_input:
Some(0)`, `last_output: Some(0)`, `last_cached_input: Some(0)`,
`last_cache_write: Some(0)`, and `last_total: Some(0)` — all have the
known-zero-at-spawn argument. Absent fields from incoming snapshots
still produce unknown (tri-state unchanged). Sessions buzz-acp did not
spawn (no seed) remain fail-closed on turn one.

**`ReportedUsage` TS mirror**: `cacheReadTokens`, `cacheWriteTokens`,
`freshInputTokens` added to `tauriArchive.ts` as `UsageField` members,
field-for-field with the Rust struct.

### P4a — aggregation layer

**Extended S-1 ladder** to cache-read and cache-write via the same
`ladder_token` path as the existing token fields.

**`freshInputTokens` derivation**: checked arithmetic, fail-closed —
absent cache fields produce Unknown (not zero), overflow and
`cacheRead+cacheWrite > input` both produce `incomplete: true`.
Aggregated as a `UsageField`.

**D6 comparator**: `sort_value()` = provider total when known, else
`input+output` when both known, else `None` (unknown-last). Replaces the
prior total-only comparator for both agent-level and model-level sort.
Ships a pinned test vector that the TS render layer (P5) must match.

## Test coverage

- `buzz-agent`: 440 lib + 15 integration (golden_transcripts) — includes
13 new `cache_total_state_tests`; 14 new `turn_io_state_tests`; 3 new
`sum_usage_*` tests (exact single-field, exact two-field, overflow
signals correctly); 3 new `parse_anthropic_*` tests (overflow flag set +
value cleared, normal sum no flag, absent usage no flag); end-to-end
golden transcript drives real subprocess with Anthropic-shaped
`input_tokens: u64::MAX, cache_read: 1` response and asserts
`accumulatedInputTokens` absent from the emitted `usage_update` — no
logic duplication; 3 wire pin tests; 4 `fold_pricing_identity_*` tests;
`pricing_authority()` explicit-:443 acceptance
- `buzz-acp`: 700 tests (691 lib + 9 integration) — 4 new usage tests
(absent input → unreliable+null; absent output → unreliable+null;
goose-shaped both present unchanged; poison mid-session); 3 ACP behavior
tests; 7 pool lifecycle tests
- Desktop (Rust): 2259+ tests — 14 new P4a pinned tests; 2 M3 round-trip
tests; 1 serde key-shape test; 2 M2 partial-schema migration tests;
first-turn cache round-trip test

## Related PRs

- P1 NIP-AM spec: [#4632](https://github.com/block/buzz/pull/4632)
- P3 pricing table: [#4629](https://github.com/block/buzz/pull/4629)
- UI (P5): [#4001](https://github.com/block/buzz/pull/4001)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-10 10:47:37 -04:00

538 lines
20 KiB
Rust

use serde::Deserialize;
use serde_json::{json, Value};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt};
use tokio::sync::mpsc;
use crate::types::{ContentBlock, McpServerStdio};
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub enum WireMsg {
Notify(Value),
}
pub type WireSender = mpsc::Sender<WireMsg>;
#[derive(Debug)]
pub enum Inbound {
Request {
id: Value,
method: String,
params: Value,
},
Notification {
method: String,
params: Value,
},
Ignored,
Invalid {
id: Value,
code: i32,
message: String,
},
}
#[derive(Debug, Deserialize)]
pub struct InitializeParams {
#[serde(rename = "protocolVersion")]
pub protocol_version: u32,
#[serde(default, rename = "clientCapabilities")]
pub _client_capabilities: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewParams {
pub cwd: String,
#[serde(default)]
pub mcp_servers: Vec<McpServerStdio>,
#[serde(default)]
pub system_prompt: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptParams {
pub session_id: String,
pub prompt: Vec<ContentBlock>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionCancelParams {
pub session_id: String,
}
/// Params for goose's non-standard `_goose/unstable/session/steer` request:
/// inject user input into the *currently active* prompt without starting a new
/// one. `expected_run_id` must match the run id buzz-agent advertised via
/// `params.update._meta.goose.activeRunId` on a `session/update`, so a steer
/// can't race a turn that already ended or hasn't started.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSteerParams {
pub session_id: String,
#[serde(default)]
pub prompt: Vec<ContentBlock>,
pub expected_run_id: String,
}
/// Params for `session/set_model`: override the active model for an existing
/// session without respawning. Applied immediately; subsequent prompts on this
/// session use `model_id` instead of the configured `BUZZ_AGENT_MODEL`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSetModelParams {
pub session_id: String,
pub model_id: String,
}
pub fn classify(msg: &Value) -> Inbound {
if !msg.is_object() || msg.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return Inbound::Invalid {
id: msg.get("id").cloned().unwrap_or(Value::Null),
code: INVALID_REQUEST,
message: "jsonrpc: missing or invalid version".into(),
};
}
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).map(str::to_owned);
let params = msg.get("params").cloned().unwrap_or(Value::Null);
match (method, id) {
(Some(m), Some(id)) => Inbound::Request {
id,
method: m,
params,
},
(Some(m), None) => Inbound::Notification { method: m, params },
// Bare responses (id present, no method) are unexpected — buzz-agent
// does not issue requests to the client. Ignore silently.
(None, Some(_)) => Inbound::Ignored,
(None, None) => Inbound::Invalid {
id: Value::Null,
code: INVALID_REQUEST,
message: "jsonrpc: missing method and id".into(),
},
}
}
pub fn ok(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
pub fn err(id: Value, code: i32, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
pub fn session_update(sid: &str, update: Value) -> Value {
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": { "sessionId": sid, "update": update },
})
}
/// A `_goose/unstable/session/update` notification — the separate top-level
/// method goose uses for custom usage and status events. Used by buzz-agent
/// to emit the `usage_update` payload so buzz-acp's `UsageTracker` can treat
/// buzz-agent and goose symmetrically.
pub fn goose_session_update(sid: &str, update: Value) -> Value {
json!({
"jsonrpc": "2.0",
"method": "_goose/unstable/session/update",
"params": { "sessionId": sid, "update": update },
})
}
/// Build the `usage_update` payload for a `_goose/unstable/session/update`.
///
/// Shared by the two places that report usage — after each LLM round inside a
/// turn, and once more when the turn completes — so the wire shape cannot drift
/// between them. A consumer takes the high-water mark per session, so the
/// mid-turn payloads are supersets of each other and the final one wins; a
/// divergence in field names or units between the two call sites would instead
/// show up as tokens silently vanishing, which is the failure this reporting
/// exists to prevent.
///
/// All counts are SESSION-cumulative, matching goose, so buzz-acp's
/// `UsageTracker` can compute per-turn deltas symmetrically for both agents.
///
/// ## `_goose/unstable/session/update` contract (ACP)
///
/// | Field | Type | Semantics |
/// |---|---|---|
/// | `sessionUpdate` | `"usage_update"` | Discriminant |
/// | `used` | `u64` | `input + output`; context-usage proxy |
/// | `contextLimit` | `u64` | `0` — buzz-agent has no context limit tracking |
/// | `accumulatedInputTokens` | `u64?` | Session-cumulative inclusive input tokens; **absent** when overflow-poisoned |
/// | `accumulatedOutputTokens` | `u64?` | Session-cumulative output tokens; **absent** when overflow-poisoned |
/// | `accumulatedCachedInputTokens` | `u64?` | Session-cumulative cache-read tokens; **absent** when never observed (harness restart, goose, or first turn); `0` when provider confirmed no cache hits |
/// | `accumulatedCacheWriteTokens` | `u64?` | Session-cumulative cache-write tokens; **absent** when never observed (same rules as cached-read) |
/// | `accumulatedTotalTokens` | `u64?` | Session-cumulative provider total; absent unless every turn reported one |
/// | `model` | `string` | Effective model id |
///
/// **Absence vs explicit zero**: both cache fields are omitted (never `null`)
/// when the running session has never observed a value for them. An old-harness
/// consumer that does not recognise these fields ignores them cleanly; a new
/// consumer that receives them absent treats them as unknown, not zero.
///
/// **Overflow poison**: `accumulatedInputTokens` and `accumulatedOutputTokens`
/// are omitted (never `null`, never `u64::MAX`) when the session-cumulative sum
/// has overflowed. A consumer that receives them absent treats them as
/// incomplete, consistent with the `accumulatedTotalTokens` contract.
///
/// **Monotonic within a session**: each notification carries a cumulative
/// snapshot that can only increase. A consumer that takes the high-water mark
/// always ends up at the final value.
///
/// **Per-category independent**: a session where `accumulatedCacheWriteTokens`
/// is absent but `accumulatedCachedInputTokens` is present is valid — the two
/// categories are tracked independently. Either can become absent (e.g. after
/// a provider switch) without poisoning the other.
pub fn usage_update_payload(
accumulated_input_tokens: Option<u64>,
accumulated_output_tokens: Option<u64>,
accumulated_cached_input_tokens: Option<u64>,
accumulated_cache_write_tokens: Option<u64>,
accumulated_total: crate::types::TurnTotalState,
model: &str,
pricing_identity: Option<&crate::types::PricingIdentity>,
) -> Value {
let mut update = json!({
"sessionUpdate": "usage_update",
// used: total tokens as a context-usage proxy; saturate when either
// side is absent or poisoned (display-only, ACP treats it as dead code).
// contextLimit: 0 (buzz-agent has no context limit tracking).
"used": accumulated_input_tokens.unwrap_or(0).saturating_add(accumulated_output_tokens.unwrap_or(0)),
"contextLimit": 0u64,
"model": model,
});
// accumulatedInputTokens / accumulatedOutputTokens: omitted (never null,
// never u64::MAX) when the session-cumulative sum has overflowed.
if let Some(input) = accumulated_input_tokens {
update["accumulatedInputTokens"] = json!(input);
}
if let Some(output) = accumulated_output_tokens {
update["accumulatedOutputTokens"] = json!(output);
}
// accumulatedCachedInputTokens: a subset of accumulatedInputTokens, not an
// addition to it. Extends goose's usage_update shape; a consumer that does
// not know the field ignores it and prices exactly as it did before.
// Absent (never emitted as null) when the session has never observed a
// cached-input value — goose never emits it, so old-harness compat is clean.
if let Some(cached) = accumulated_cached_input_tokens {
update["accumulatedCachedInputTokens"] = json!(cached);
}
// accumulatedCacheWriteTokens: a subset of accumulatedInputTokens, not an
// addition to it. Absent when never observed (same provenance rules as
// accumulatedCachedInputTokens).
if let Some(written) = accumulated_cache_write_tokens {
update["accumulatedCacheWriteTokens"] = json!(written);
}
// Only when the cumulative is exactly known — never when Unseen (no total
// ever observed) or Unknown (at least one turn lacked a total). A goose
// consumer that doesn't recognise the field ignores it.
if let Some(total) = accumulated_total.exact_value() {
update["accumulatedTotalTokens"] = json!(total);
}
// pricingIdentity: present only when the publisher proved applicability.
// Absent (never null) when unproven — old consumers ignore the field.
if let Some(pi) = pricing_identity {
update["pricingIdentity"] = serde_json::to_value(pi).unwrap_or(serde_json::Value::Null);
}
update
}
/// A `session/update` notification carrying a `update._meta.goose.<key>` field.
/// Used to advertise `activeRunId` (so steer-capable clients can target the
/// in-flight run) and `queuedSteer` (so they can correlate an accepted steer
/// with the chunk that later picks it up) — matching goose's wire layout where
/// `_meta` is nested inside the `update` object (per the ACP `SessionInfoUpdate`
/// schema), not alongside it at the params level.
pub fn session_update_with_goose_meta(sid: &str, update: Value, goose_meta: Value) -> Value {
let mut update = update;
update["_meta"] = json!({ "goose": goose_meta });
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": sid,
"update": update,
},
})
}
pub async fn send(wire: &WireSender, msg: Value) {
let _ = wire.send(WireMsg::Notify(msg)).await;
}
pub async fn read_bounded_line<R: AsyncBufRead + Unpin>(
stdin: &mut R,
max: usize,
) -> std::io::Result<Option<String>> {
let mut buf: Vec<u8> = Vec::new();
loop {
let chunk = stdin.fill_buf().await?;
if chunk.is_empty() {
if !buf.is_empty() {
tracing::error!(
"io: unterminated frame at EOF ({} bytes dropped)",
buf.len()
);
}
return Ok(None);
}
let take = chunk
.iter()
.position(|b| *b == b'\n')
.map_or(chunk.len(), |i| i + 1);
if buf.len().saturating_add(take) > max {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("io: line exceeds max ({max} bytes)"),
));
}
buf.extend_from_slice(&chunk[..take]);
stdin.consume(take);
if buf.ends_with(b"\n") {
buf.pop();
if buf.ends_with(b"\r") {
buf.pop();
}
match String::from_utf8(buf) {
Ok(s) => return Ok(Some(s)),
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"io: frame contains invalid UTF-8",
))
}
}
}
}
}
pub async fn writer_task(mut rx: mpsc::Receiver<WireMsg>) {
let mut stdout = tokio::io::stdout();
while let Some(msg) = rx.recv().await {
let WireMsg::Notify(v) = msg;
let mut s = match serde_json::to_string(&v) {
Ok(s) => s,
Err(e) => {
tracing::error!("io: serialize: {e}");
continue;
}
};
s.push('\n');
if stdout.write_all(s.as_bytes()).await.is_err() {
return;
}
let _ = stdout.flush().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_new_params_deserializes_system_prompt() {
let json = serde_json::json!({
"cwd": "/tmp/test",
"mcpServers": [],
"systemPrompt": "You are a helpful agent."
});
let params: SessionNewParams = serde_json::from_value(json).unwrap();
assert_eq!(params.cwd, "/tmp/test");
assert_eq!(
params.system_prompt.as_deref(),
Some("You are a helpful agent.")
);
}
#[test]
fn session_new_params_system_prompt_defaults_to_none() {
let json = serde_json::json!({
"cwd": "/tmp/test",
"mcpServers": []
});
let params: SessionNewParams = serde_json::from_value(json).unwrap();
assert_eq!(params.cwd, "/tmp/test");
assert!(params.system_prompt.is_none());
}
#[test]
fn session_new_params_ignores_unknown_fields() {
// Backward compat: old agents with new harness — unknown fields are ignored.
let json = serde_json::json!({
"cwd": "/tmp/test",
"mcpServers": [],
"unknownField": "should be ignored"
});
let params: SessionNewParams = serde_json::from_value(json).unwrap();
assert_eq!(params.cwd, "/tmp/test");
assert!(params.system_prompt.is_none());
}
#[test]
fn session_new_params_empty_string_system_prompt() {
// An explicit empty string is distinct from absent — deserializes to Some("").
let json = serde_json::json!({
"cwd": "/tmp/test",
"mcpServers": [],
"systemPrompt": ""
});
let params: SessionNewParams = serde_json::from_value(json).unwrap();
assert_eq!(params.system_prompt, Some(String::new()));
}
// ── usage_update_payload: pricingIdentity emission ───────────────────────
fn make_pi(authority: &str, model: &str) -> crate::types::PricingIdentity {
crate::types::PricingIdentity {
authority: authority.to_string(),
model: model.to_string(),
cache_class: None,
}
}
/// When a proven identity is passed, the wire payload MUST include a
/// `pricingIdentity` object with camelCase keys, and it MUST NOT be null.
#[test]
fn usage_update_payload_includes_pricing_identity_when_proven() {
let pi = make_pi("api.anthropic.com", "claude-opus-4-5");
let payload = usage_update_payload(
Some(1000),
Some(200),
None,
None,
crate::types::TurnTotalState::Unseen,
"claude-opus-4-5",
Some(&pi),
);
let pi_wire = &payload["pricingIdentity"];
assert!(
!pi_wire.is_null(),
"pricingIdentity must be present when identity is proven"
);
assert_eq!(pi_wire["authority"], serde_json::json!("api.anthropic.com"));
assert_eq!(pi_wire["model"], serde_json::json!("claude-opus-4-5"));
// cacheClass absent when None (skip_serializing_if)
assert!(pi_wire.get("cacheClass").is_none() || pi_wire["cacheClass"].is_null());
}
/// When `pricing_identity` is `None` (unproven: custom endpoint, mixed
/// identities, etc.), the field MUST be absent from the wire payload.
/// It must never appear as `null`.
#[test]
fn usage_update_payload_omits_pricing_identity_when_absent() {
let payload = usage_update_payload(
Some(500),
Some(100),
None,
None,
crate::types::TurnTotalState::Unseen,
"some-model",
None, // no proven identity
);
assert!(
payload.get("pricingIdentity").is_none(),
"pricingIdentity must be absent (never null) when identity is unproven"
);
}
/// A custom endpoint (Databricks, corporate proxy) must not produce a
/// wire `pricingIdentity` — it must be absent.
///
/// This relies on the caller passing `None`; the payload builder must not
/// inject a default. This test documents the contract.
#[test]
fn usage_update_payload_no_pricing_identity_for_custom_endpoint() {
// Custom endpoint → caller passes None (pricing_authority returned None).
let payload = usage_update_payload(
Some(800),
Some(150),
Some(200),
None,
crate::types::TurnTotalState::Unseen,
"databricks-llama-4",
None,
);
assert!(
payload.get("pricingIdentity").is_none(),
"custom endpoint: pricingIdentity must not appear on the wire"
);
// Cache field must still be present (independent).
assert_eq!(
payload["accumulatedCachedInputTokens"],
serde_json::json!(200)
);
}
/// When input is overflow-poisoned (None), the wire payload must omit
/// `accumulatedInputTokens` entirely — never null, never u64::MAX.
#[test]
fn usage_update_payload_omits_input_when_poisoned() {
let payload = usage_update_payload(
None, // overflow-poisoned
Some(150),
None,
None,
crate::types::TurnTotalState::Unseen,
"model",
None,
);
assert!(
payload.get("accumulatedInputTokens").is_none(),
"poisoned accumulatedInputTokens must be absent, not null or MAX"
);
// output still present
assert_eq!(payload["accumulatedOutputTokens"], serde_json::json!(150));
}
/// When output is overflow-poisoned (None), the wire payload must omit
/// `accumulatedOutputTokens` entirely — never null, never u64::MAX.
#[test]
fn usage_update_payload_omits_output_when_poisoned() {
let payload = usage_update_payload(
Some(800),
None, // overflow-poisoned
None,
None,
crate::types::TurnTotalState::Unseen,
"model",
None,
);
assert!(
payload.get("accumulatedOutputTokens").is_none(),
"poisoned accumulatedOutputTokens must be absent, not null or MAX"
);
// input still present
assert_eq!(payload["accumulatedInputTokens"], serde_json::json!(800));
}
/// When both are present and exact, the wire payload emits both at their
/// values (unchanged goose-compatible behavior).
#[test]
fn usage_update_payload_emits_both_when_exact() {
let payload = usage_update_payload(
Some(1000),
Some(200),
None,
None,
crate::types::TurnTotalState::Unseen,
"model",
None,
);
assert_eq!(payload["accumulatedInputTokens"], serde_json::json!(1000));
assert_eq!(payload["accumulatedOutputTokens"], serde_json::json!(200));
}
}