feat(acp,buzz-agent): thread model name through NIP-AM kind 44200 emit path (#1564)

Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-06 21:12:42 -04:00
committed by GitHub
parent 04a9d10299
commit 08e707eb57
3 changed files with 83 additions and 1 deletions

View File

@@ -2743,7 +2743,7 @@ async fn publish_agent_turn_metric(
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let payload = AgentTurnMetricPayload {
harness: ctx.harness_name.clone(),
model: None,
model: usage.model.clone(),
channel_id: channel_id.map(|id| id.to_string()),
session_id: Some(usage.session_id.clone()),
turn_id: Some(turn_id.to_string()),
@@ -3965,6 +3965,7 @@ mod tests {
cumulative_input_tokens: 100,
cumulative_output_tokens: 50,
cumulative_cost_usd: None,
model: None,
};
// owner_pubkey = None → early return, no panic.
publish_agent_turn_metric(
@@ -3996,6 +3997,7 @@ mod tests {
cumulative_input_tokens: 200,
cumulative_output_tokens: 80,
cumulative_cost_usd: Some(0.001),
model: None,
};
// Will try to publish and fail (no real relay) but must not panic.
publish_agent_turn_metric(
@@ -4028,6 +4030,7 @@ mod tests {
cumulative_input_tokens: 150,
cumulative_output_tokens: 70,
cumulative_cost_usd: None,
model: None,
};
// Must not panic; HTTP submit will fail (no real relay) — that's fine.
publish_agent_turn_metric(
@@ -4060,6 +4063,7 @@ mod tests {
cumulative_input_tokens: 400,
cumulative_output_tokens: 100,
cumulative_cost_usd: None,
model: None,
};
// Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic.
publish_agent_turn_metric(

View File

@@ -86,6 +86,10 @@ pub(crate) struct UsageUpdatePayload {
pub accumulated_input_tokens: u64,
pub accumulated_output_tokens: u64,
pub accumulated_cost: Option<f64>,
/// Effective model id for this turn. Optional — goose payloads that
/// predate this field deserialize cleanly as `None`.
#[serde(default)]
pub model: Option<String>,
}
/// Per-session normalization state: the last cumulative snapshot we saw.
@@ -130,6 +134,9 @@ pub struct TurnUsage {
pub cumulative_output_tokens: u64,
/// Session-cumulative estimated cost in USD; `None` if goose did not report it.
pub cumulative_cost_usd: Option<f64>,
/// 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<String>,
}
/// Tracks per-session cumulative usage state across turns.
@@ -262,6 +269,7 @@ impl UsageTracker {
cumulative_input_tokens: current_input,
cumulative_output_tokens: current_output,
cumulative_cost_usd: current_cost,
model: payload.model.clone(),
});
} else if self.in_flight_session.is_none() {
// Not in-flight at all: advance the committed baseline so the next
@@ -322,6 +330,7 @@ mod tests {
accumulated_input_tokens: input,
accumulated_output_tokens: output,
accumulated_cost: cost,
model: None,
}
}
@@ -332,6 +341,7 @@ mod tests {
accumulated_input_tokens: input,
accumulated_output_tokens: output,
accumulated_cost: cost,
model: None,
}
}
@@ -812,4 +822,71 @@ mod tests {
"take() without any record() must return None (pre-response cancel path)"
);
}
// ── model field threading ────────────────────────────────────────────────
fn payload_with_model(
input: u64,
output: u64,
cost: Option<f64>,
model: Option<&str>,
) -> UsageUpdatePayload {
UsageUpdatePayload {
used: input + output,
context_limit: 200_000,
accumulated_input_tokens: input,
accumulated_output_tokens: output,
accumulated_cost: cost,
model: model.map(str::to_string),
}
}
#[test]
fn model_threads_from_payload_to_turn_usage() {
// When a `usage_update` payload includes a `model` field, TurnUsage
// must carry it through so pool.rs can populate the 44200 payload.
let mut tracker = UsageTracker::default();
let p = payload_with_model(1000, 200, None, Some("claude-sonnet-4-5"));
tracker.begin_turn("sess-model");
tracker.record("sess-model", &p);
let usage = tracker.take().expect("pending");
assert_eq!(
usage.model.as_deref(),
Some("claude-sonnet-4-5"),
"model must pass through record() → pending → take()"
);
}
#[test]
fn model_none_when_payload_omits_model_field() {
// Goose payloads that predate the `model` field must deserialize cleanly
// and produce TurnUsage with model = None (no deserialization error,
// no panic — goose-parity / fail-soft contract).
let json = r#"{
"sessionUpdate": "usage_update",
"accumulatedInputTokens": 500,
"accumulatedOutputTokens": 100,
"accumulatedCost": 0.005
}"#;
let variant: GooseSessionUpdateVariant =
serde_json::from_str(json).expect("must deserialize without model field");
let payload = match variant {
GooseSessionUpdateVariant::UsageUpdate(p) => p,
_ => panic!("expected UsageUpdate variant"),
};
assert!(
payload.model.is_none(),
"model must be None when absent from wire payload"
);
// And it should produce a TurnUsage with model = None.
let mut tracker = UsageTracker::default();
tracker.begin_turn("sess-goose-compat");
tracker.record("sess-goose-compat", &payload);
let usage = tracker.take().expect("pending");
assert!(
usage.model.is_none(),
"TurnUsage.model must be None when payload omits the field"
);
}
}

View File

@@ -728,6 +728,7 @@ async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender
"contextLimit": 0u64,
"accumulatedInputTokens": accumulated_in,
"accumulatedOutputTokens": accumulated_out,
"model": effective_model_str,
}),
),
)