diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 06383d456d3..7223e3ea643 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -2127,11 +2127,15 @@ pub async fn run_prompt_task( turn_id: String, ) { // Is this a channel prompt or a heartbeat? - let source = match &batch { + let source = match batch.as_ref() { Some(b) => PromptSource::Channel(b.scope.clone()), None => PromptSource::Heartbeat, }; let observer_channel_id = source.channel_id(); + let observer_thread_root_id = match &source { + PromptSource::Channel(scope) => scope.root_event_id().map(str::to_string), + PromptSource::Heartbeat => None, + }; let turn_started_at = chrono::Utc::now().to_rfc3339(); agent.acp.set_observer_context(observer::context_for_turn( observer_channel_id, @@ -2576,6 +2580,7 @@ pub async fn run_prompt_task( &ctx, usage, Some(*cid), + scope.root_event_id(), &session_id, &format!("{turn_id}:initial"), Some(acp_stop_to_core(&stop_reason)), @@ -2611,6 +2616,7 @@ pub async fn run_prompt_task( &ctx, usage, Some(*cid), + scope.root_event_id(), &session_id, &format!("{turn_id}:initial"), Some(acp_stop_to_core(&stop_reason)), @@ -2924,6 +2930,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::Cancelled), @@ -2960,6 +2967,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::Error), @@ -3025,6 +3033,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::EndTurn), @@ -3099,6 +3108,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(core_stop), @@ -3122,6 +3132,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::Error), @@ -3154,6 +3165,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::Cancelled), @@ -3182,6 +3194,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::Error), @@ -3207,6 +3220,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::Error), @@ -3236,6 +3250,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::Error), @@ -3263,6 +3278,7 @@ pub async fn run_prompt_task( &ctx, usage, observer_channel_id, + observer_thread_root_id.as_deref(), &session_id, &turn_id, Some(buzz_core::agent_turn_metric::StopReason::Error), @@ -4911,6 +4927,7 @@ async fn publish_agent_turn_metric( ctx: &PromptContext, usage: Option, channel_id: Option, + thread_root_id: Option<&str>, session_id: &str, turn_id: &str, stop_reason: Option, @@ -4929,6 +4946,7 @@ async fn publish_agent_turn_metric( harness: ctx.harness_name.clone(), model: usage.model.clone(), channel_id: channel_id.map(|id| id.to_string()), + thread_root_id: thread_root_id.map(str::to_string), session_id: Some(usage.session_id.clone()), turn_id: Some(turn_id.to_string()), turn_seq: Some(usage.turn_seq), @@ -4937,6 +4955,9 @@ async fn publish_agent_turn_metric( cumulative: cumulative_counts, delta_reliable: usage.delta_reliable, stop_reason, + context_used_tokens: usage.context_used_tokens, + context_limit_tokens: usage.context_limit_tokens, + account_usage_windows: Vec::new(), pricing_identity: usage.pricing_identity.clone(), }; let ciphertext = match buzz_core::agent_turn_metric::encrypt_agent_turn_metric( @@ -8551,6 +8572,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" &ctx, None, None, + None, "sess-1", "turn-1", Some(buzz_core::agent_turn_metric::StopReason::EndTurn), @@ -8579,6 +8601,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" cumulative_cache_read_tokens: None, cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }; // owner_pubkey = None → early return, no panic. @@ -8586,6 +8610,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" &ctx, Some(usage), None, + None, "sess-1", "turn-1", Some(buzz_core::agent_turn_metric::StopReason::EndTurn), @@ -8618,6 +8643,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" cumulative_cache_read_tokens: None, cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }; // Will try to publish and fail (no real relay) but must not panic. @@ -8625,6 +8652,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" &ctx, Some(usage), Some(uuid::Uuid::new_v4()), + None, "sess-1", "turn-1", Some(buzz_core::agent_turn_metric::StopReason::EndTurn), @@ -8658,6 +8686,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" cumulative_cache_read_tokens: None, cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. @@ -8665,6 +8695,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" &ctx, Some(usage), Some(uuid::Uuid::new_v4()), + None, "sess-cancel", "turn-cancel", Some(buzz_core::agent_turn_metric::StopReason::Cancelled), @@ -8698,6 +8729,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" cumulative_cache_read_tokens: None, cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. @@ -8705,6 +8738,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" &ctx, Some(usage), Some(uuid::Uuid::new_v4()), + None, "sess-ba", "turn-ba", Some(buzz_core::agent_turn_metric::StopReason::EndTurn), @@ -8735,6 +8769,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" cumulative_cache_read_tokens: None, cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }; @@ -8787,6 +8823,8 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" cumulative_cache_read_tokens: None, cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }; diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 2197b99ef5f..9abbd71cb55 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -238,6 +238,10 @@ pub struct TurnUsage { /// 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, + /// Input-side context used by the last successful model request this turn. + pub context_used_tokens: Option, + /// Context-window capacity paired with `context_used_tokens`. + pub context_limit_tokens: Option, /// Billing identity for this turn, as received from the publisher. /// `None` when the publisher omitted it (unrecognised endpoint, mixed /// identities, old harness). Per-turn only — not session-cumulative. @@ -391,6 +395,8 @@ impl StandardUsageTracker { cumulative_cache_read_tokens: None, cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }) } @@ -729,6 +735,12 @@ impl UsageTracker { // Already poisoned: stays poisoned regardless of this notification. poisoned @ Some(None) => poisoned, }; + let (context_used_tokens, context_limit_tokens) = + if payload.used > 0 && payload.context_limit > 0 { + (Some(payload.used), Some(payload.context_limit)) + } else { + (None, None) + }; self.pending = Some(TurnUsage { session_id: session_id.to_string(), turn_seq, @@ -746,6 +758,8 @@ impl UsageTracker { cumulative_cache_read_tokens: current_cached_input, cumulative_cache_write_tokens: current_cache_write, model: payload.model.clone(), + context_used_tokens, + context_limit_tokens, // The folded identity is written in take() — use a placeholder // here and replace it before returning the record. pricing_identity: None, @@ -1009,6 +1023,29 @@ mod tests { } } + // ── Context-window snapshot threading ─────────────────────────────────── + + #[test] + fn context_snapshot_is_last_write_wins_and_invalid_pair_means_unknown() { + let mut tracker = UsageTracker::default(); + tracker.seed_zero_baseline("sess-context"); + tracker.begin_turn("sess-context"); + + let mut first = payload(100, 20, None); + first.used = 90; + first.context_limit = 200_000; + tracker.record("sess-context", &first); + + let mut final_snapshot = payload(180, 40, None); + final_snapshot.used = 210_000; + final_snapshot.context_limit = 0; + tracker.record("sess-context", &final_snapshot); + + let usage = tracker.take().expect("turn usage"); + assert_eq!(usage.context_used_tokens, None); + assert_eq!(usage.context_limit_tokens, None); + } + // ── Turn scoping: setup notifications must not pollute the first real turn ─ #[test] @@ -1988,6 +2025,8 @@ mod tests { cumulative_cache_read_tokens: None, // harness did not report the field cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }; @@ -2029,6 +2068,8 @@ mod tests { cumulative_cache_read_tokens: Some(600), cumulative_cache_write_tokens: None, model: None, + context_used_tokens: None, + context_limit_tokens: None, pricing_identity: None, }; diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 5125b280747..2fe8980b905 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -292,22 +292,27 @@ impl RunCtx<'_> { let write_total = base .cache_write_tokens .merge_session(*self.turn_cache_write_tokens); - let payload = wire::usage_update_payload( - base.input_tokens + let payload = wire::usage_update_payload_with_context(wire::UsageUpdateSnapshot { + accumulated_input_tokens: base + .input_tokens .merge_session(*self.turn_input_tokens) .exact_value(), - base.output_tokens + accumulated_output_tokens: base + .output_tokens .merge_session(*self.turn_output_tokens) .exact_value(), - cached_total.exact_value(), - write_total.exact_value(), - base.total_state.merge_session(*self.turn_total_state), - self.effective_model, + accumulated_cached_input_tokens: cached_total.exact_value(), + accumulated_cache_write_tokens: write_total.exact_value(), + accumulated_total: base.total_state.merge_session(*self.turn_total_state), + model: self.effective_model, // Extract the proven identity if this turn is consistent so far. - self.turn_pricing_identity + pricing_identity: self + .turn_pricing_identity .as_ref() .and_then(|inner| inner.as_ref()), - ); + context_used_tokens: *self.last_request_input_tokens, + context_limit_tokens: Some(self.cfg.max_context_tokens), + }); wire::send( self.wire, wire::goose_session_update(self.session_id, payload), diff --git a/crates/buzz-agent/src/wire.rs b/crates/buzz-agent/src/wire.rs index e6fe89e07cc..899e311bc8d 100644 --- a/crates/buzz-agent/src/wire.rs +++ b/crates/buzz-agent/src/wire.rs @@ -305,13 +305,57 @@ pub fn usage_update_payload( model: &str, pricing_identity: Option<&crate::types::PricingIdentity>, ) -> Value { + usage_update_payload_with_context(UsageUpdateSnapshot { + accumulated_input_tokens, + accumulated_output_tokens, + accumulated_cached_input_tokens, + accumulated_cache_write_tokens, + accumulated_total, + model, + pricing_identity, + context_used_tokens: Some( + accumulated_input_tokens + .unwrap_or(0) + .saturating_add(accumulated_output_tokens.unwrap_or(0)), + ), + context_limit_tokens: None, + }) +} + +/// Named usage values prevent cumulative counters and point-in-time context +/// values from being interchanged at call sites. +pub struct UsageUpdateSnapshot<'a> { + pub accumulated_input_tokens: Option, + pub accumulated_output_tokens: Option, + pub accumulated_cached_input_tokens: Option, + pub accumulated_cache_write_tokens: Option, + pub accumulated_total: crate::types::TurnTotalState, + pub model: &'a str, + pub pricing_identity: Option<&'a crate::types::PricingIdentity>, + pub context_used_tokens: Option, + pub context_limit_tokens: Option, +} + +/// Build a usage update with a point-in-time context-window snapshot. +pub fn usage_update_payload_with_context(snapshot: UsageUpdateSnapshot<'_>) -> Value { + let UsageUpdateSnapshot { + accumulated_input_tokens, + accumulated_output_tokens, + accumulated_cached_input_tokens, + accumulated_cache_write_tokens, + accumulated_total, + model, + pricing_identity, + context_used_tokens, + context_limit_tokens, + } = snapshot; 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, + "used": context_used_tokens.unwrap_or(0), + "contextLimit": context_limit_tokens.unwrap_or(0), "model": model, }); // accumulatedInputTokens / accumulatedOutputTokens: omitted (never null, @@ -534,14 +578,14 @@ mod tests { /// `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 pi = make_pi("api.provider.example", "model-alpha"); let payload = usage_update_payload( Some(1000), Some(200), None, None, crate::types::TurnTotalState::Unseen, - "claude-opus-4-5", + "model-alpha", Some(&pi), ); @@ -550,12 +594,32 @@ mod tests { !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")); + assert_eq!( + pi_wire["authority"], + serde_json::json!("api.provider.example") + ); + assert_eq!(pi_wire["model"], serde_json::json!("model-alpha")); // cacheClass absent when None (skip_serializing_if) assert!(pi_wire.get("cacheClass").is_none() || pi_wire["cacheClass"].is_null()); } + #[test] + fn usage_update_payload_with_context_uses_last_request_snapshot() { + let payload = usage_update_payload_with_context(UsageUpdateSnapshot { + accumulated_input_tokens: Some(50_000), + accumulated_output_tokens: Some(2_000), + accumulated_cached_input_tokens: None, + accumulated_cache_write_tokens: None, + accumulated_total: crate::types::TurnTotalState::Unseen, + model: "model-alpha", + pricing_identity: None, + context_used_tokens: Some(42_000), + context_limit_tokens: Some(200_000), + }); + assert_eq!(payload["used"], serde_json::json!(42_000)); + assert_eq!(payload["contextLimit"], serde_json::json!(200_000)); + } + /// 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`. diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index 9822243d5fe..ed8fb882ac9 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -1415,20 +1415,20 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { }) .await; - // Now send cancel and release the round-2 gate. Cancel is enqueued before - // round 2 can respond, so the turn exits with stopReason: cancelled. + // Wait for the cancel acknowledgement before releasing round 2. Merely + // writing cancel before opening the gate does not guarantee the server task + // processes it first under scheduler contention. let c_id = h.send("session/cancel", json!({"sessionId": sid})).await; + h.recv_until(|v| v["id"] == json!(c_id)).await; let _ = gate_tx.send(()); // unblock round 2 let mut saw_usage_before_prompt_response = false; let mut saw_usage = false; - let mut saw_cancel_ok = false; + let saw_cancel_ok = true; let mut saw_prompt_response = false; for _ in 0..40 { let v = h.recv().await; - if v["id"] == json!(c_id) { - saw_cancel_ok = true; - } else if is_usage_update(&v) { + if is_usage_update(&v) { saw_usage = true; if !saw_prompt_response { saw_usage_before_prompt_response = true; @@ -1438,7 +1438,7 @@ async fn cancelled_turn_with_usage_emits_notification_before_response() { // The gate guarantees stopReason: cancelled — not a race-driven error. assert_eq!( v["result"]["stopReason"], "cancelled", - "turn must end with stopReason: cancelled" + "turn must end with stopReason: cancelled; response={v}" ); } if saw_usage && saw_prompt_response && saw_cancel_ok { diff --git a/crates/buzz-cli/src/commands/metrics.rs b/crates/buzz-cli/src/commands/metrics.rs new file mode 100644 index 00000000000..165592453e1 --- /dev/null +++ b/crates/buzz-cli/src/commands/metrics.rs @@ -0,0 +1,68 @@ +//! `buzz metrics publish` — encrypted NIP-AM agent snapshots. + +use std::io::Read; + +use buzz_core::agent_turn_metric::{encrypt_agent_turn_metric, AgentTurnMetricPayload}; +use nostr::{EventBuilder, Kind, PublicKey, Tag}; + +use crate::client::BuzzClient; +use crate::error::CliError; +use crate::MetricsCmd; + +fn read_payload_arg(value: &str) -> Result { + if value != "-" { + return Ok(value.to_string()); + } + let mut input = String::new(); + std::io::stdin() + .read_to_string(&mut input) + .map_err(|error| { + CliError::Other(format!("failed to read metric payload from stdin: {error}")) + })?; + if input.trim().is_empty() { + return Err(CliError::Usage("metric payload on stdin is empty".into())); + } + Ok(input) +} + +pub async fn dispatch(cmd: MetricsCmd, client: &BuzzClient) -> Result<(), CliError> { + match cmd { + MetricsCmd::Publish { payload } => { + let raw = read_payload_arg(&payload)?; + let metric: AgentTurnMetricPayload = serde_json::from_str(&raw).map_err(|error| { + CliError::Usage(format!("invalid NIP-AM payload JSON: {error}")) + })?; + metric + .validate() + .map_err(|error| CliError::Usage(format!("invalid NIP-AM payload: {error}")))?; + + let owner_hex = client.auth_tag_owner_hex().ok_or_else(|| { + CliError::Auth( + "metrics publish requires BUZZ_AUTH_TAG so the owner encryption key is known" + .into(), + ) + })?; + let owner = PublicKey::from_hex(&owner_hex).map_err(|error| { + CliError::Auth(format!("invalid owner pubkey in auth tag: {error}")) + })?; + let ciphertext = encrypt_agent_turn_metric(client.keys(), &owner, &metric) + .map_err(|error| CliError::Other(format!("metric encryption failed: {error}")))?; + let agent_hex = client.keys().public_key().to_hex(); + let event = client.sign_event( + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_AGENT_TURN_METRIC as u16), + ciphertext, + ) + .tags([ + Tag::parse(["p", &owner_hex]) + .map_err(|error| CliError::Other(format!("invalid owner tag: {error}")))?, + Tag::parse(["agent", &agent_hex]) + .map_err(|error| CliError::Other(format!("invalid agent tag: {error}")))?, + ]), + )?; + let response = client.submit_event(event).await?; + println!("{response}"); + Ok(()) + } + } +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 7ed03f9d060..6ec484e2461 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod gifs; pub mod issues; pub mod mem; pub mod messages; +pub mod metrics; pub mod moderation; pub mod notes; pub mod pack; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3f2bea73979..099ea9fcd58 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -237,6 +237,9 @@ enum Cmd { /// Agent engram management — persistent memory per NIP-AE #[command(subcommand)] Mem(MemCmd), + /// Publish encrypted agent usage/context snapshots (NIP-AM) + #[command(subcommand)] + Metrics(MetricsCmd), /// Persona pack operations (local, no relay connection needed) #[command(subcommand)] Pack(PackCmd), @@ -263,6 +266,15 @@ impl RespondToArg { } } +#[derive(Subcommand)] +pub enum MetricsCmd { + /// Encrypt and publish one NIP-AM payload; use '-' to read JSON from stdin + Publish { + #[arg(long)] + payload: String, + }, +} + #[derive(Subcommand)] pub enum AgentsCmd { /// Open a prefilled create-agent form in the owner's Buzz Desktop @@ -2123,6 +2135,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Media(sub) => commands::upload::dispatch_media(sub, &client).await, Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await, Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await, + Cmd::Metrics(sub) => commands::metrics::dispatch(sub, &client).await, Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await, Cmd::Pack(_) => unreachable!("handled above"), } @@ -2263,6 +2276,7 @@ mod tests { "media", "mem", "messages", + "metrics", "moderation", "notes", "pack", @@ -2361,6 +2375,7 @@ mod tests { ] ); assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]); + assert_eq!(names(&cmd, "metrics"), vec!["publish"]); assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]); assert_eq!( names(&cmd, "emoji"), diff --git a/crates/buzz-core/src/agent_turn_metric.rs b/crates/buzz-core/src/agent_turn_metric.rs index cbf2dd24342..a0c3b5ab9e3 100644 --- a/crates/buzz-core/src/agent_turn_metric.rs +++ b/crates/buzz-core/src/agent_turn_metric.rs @@ -6,6 +6,7 @@ //! //! See `docs/nips/NIP-AM.md` for the full specification. +use chrono::DateTime; use nostr::{Event, Keys, PublicKey}; use serde::{Deserialize, Serialize}; @@ -120,6 +121,11 @@ pub struct AgentTurnMetricPayload { /// Channel UUID the turn served, encrypted inside the payload. pub channel_id: Option, + /// Canonical thread-root event id when the turn used a thread-scoped + /// session. Omitted for channel-wide conversations, DMs, and heartbeats. + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_root_id: Option, + /// Session identifier. REQUIRED when `cumulative` is present. pub session_id: Option, @@ -150,6 +156,23 @@ pub struct AgentTurnMetricPayload { /// Why the turn ended. Unrecognized values MUST be treated as `Unknown`. pub stop_reason: Option, + /// Input-side context tokens in the last successful model request of this + /// turn. This is a point-in-time context-window snapshot, not cumulative + /// session usage. Omitted when unknown or reported as zero. + #[serde(skip_serializing_if = "Option::is_none")] + pub context_used_tokens: Option, + + /// Configured context-window capacity corresponding to + /// `context_used_tokens`. Omitted when unknown or reported as zero. + #[serde(skip_serializing_if = "Option::is_none")] + pub context_limit_tokens: Option, + + /// Provider-account rate-limit windows reported by the agent runtime. + /// The payload is encrypted; credentials and bearer tokens never belong + /// here. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub account_usage_windows: Vec, + /// Billing identity, present only when the publisher can prove it from the /// actual endpoint (official provider API) and the actually-requested model. /// @@ -159,17 +182,61 @@ pub struct AgentTurnMetricPayload { pub pricing_identity: Option, } +/// A provider-account quota window for a native usage gauge. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountUsageWindow { + /// Provider-facing label such as `Session`, `Weekly`, or `Sonnet week`. + pub label: String, + /// Percentage consumed. Consumers clamp this only for visual rendering. + pub used_percent: f64, + /// Provider-reported RFC 3339 reset instant, when available. + #[serde(skip_serializing_if = "Option::is_none")] + pub reset_at: Option, +} + fn default_delta_reliable() -> bool { true } impl AgentTurnMetricPayload { - /// Validate numeric constraints from NIP-AM §Numeric validity. + /// Validate the NIP-AM payload contract before encryption or after decryption. /// - /// Returns `Err` when any `cost_usd` field (in `turn` or `cumulative`) is - /// present but negative or non-finite (NaN or infinity). Token counts are - /// typed as `Option` and therefore cannot be negative by construction. + /// Returns `Err` when required fields, structural relationships, bounded + /// strings/windows, RFC 3339 times, pricing identity, context pairs, or + /// numeric values violate NIP-AM. Token counts are `Option` and cannot + /// be negative by construction. pub fn validate(&self) -> Result<(), ObserverPayloadError> { + const MAX_HARNESS_LEN: usize = 128; + const MAX_IDENTIFIER_LEN: usize = 256; + const MAX_USAGE_WINDOWS: usize = 64; + const MAX_WINDOW_LABEL_LEN: usize = 128; + + fn invalid(message: impl Into) -> ObserverPayloadError { + ObserverPayloadError::InvalidPayload(message.into()) + } + + fn check_optional_text( + value: Option<&str>, + field: &str, + max_len: usize, + ) -> Result<(), ObserverPayloadError> { + if let Some(value) = value { + if value.trim().is_empty() || value.len() > max_len { + return Err(invalid(format!( + "{field} must be non-empty and at most {max_len} bytes" + ))); + } + } + Ok(()) + } + + fn check_rfc3339(value: &str, field: &str) -> Result<(), ObserverPayloadError> { + DateTime::parse_from_rfc3339(value) + .map(|_| ()) + .map_err(|_| invalid(format!("{field} must be RFC 3339"))) + } + fn check_cost(cost: Option, field: &str) -> Result<(), ObserverPayloadError> { if let Some(c) = cost { if !c.is_finite() || c < 0.0 { @@ -180,12 +247,115 @@ impl AgentTurnMetricPayload { } Ok(()) } + + fn counts_have_observation(counts: &TokenCounts) -> bool { + counts.input_tokens.is_some() + || counts.output_tokens.is_some() + || counts.total_tokens.is_some() + || counts.cost_usd.is_some() + || counts.cache_read_tokens.is_some() + || counts.cache_write_tokens.is_some() + } + + if self.harness.trim().is_empty() || self.harness.len() > MAX_HARNESS_LEN { + return Err(invalid(format!( + "harness must be non-empty and at most {MAX_HARNESS_LEN} bytes" + ))); + } + check_rfc3339(&self.timestamp, "timestamp")?; + check_optional_text(self.model.as_deref(), "model", MAX_IDENTIFIER_LEN)?; + check_optional_text(self.channel_id.as_deref(), "channelId", MAX_IDENTIFIER_LEN)?; + check_optional_text(self.session_id.as_deref(), "sessionId", MAX_IDENTIFIER_LEN)?; + check_optional_text(self.turn_id.as_deref(), "turnId", MAX_IDENTIFIER_LEN)?; + + if let Some(thread_root_id) = self.thread_root_id.as_deref() { + if self.channel_id.is_none() + || thread_root_id.len() != 64 + || !thread_root_id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid( + "threadRootId requires channelId and must be 64 lowercase hex characters", + )); + } + } + + if self.cumulative.is_some() && (self.session_id.is_none() || self.turn_seq.is_none()) { + return Err(invalid( + "sessionId and turnSeq are required when cumulative is present", + )); + } + + match (self.context_used_tokens, self.context_limit_tokens) { + (None, None) => {} + (Some(used), Some(limit)) if used > 0 && limit > 0 => {} + _ => { + return Err(invalid( + "contextUsedTokens and contextLimitTokens must be a complete positive pair", + )); + } + } + if let Some(t) = &self.turn { check_cost(t.cost_usd, "turn.costUsd")?; } if let Some(c) = &self.cumulative { check_cost(c.cost_usd, "cumulative.costUsd")?; } + if self.account_usage_windows.len() > MAX_USAGE_WINDOWS { + return Err(invalid(format!( + "accountUsageWindows must contain at most {MAX_USAGE_WINDOWS} entries" + ))); + } + for window in &self.account_usage_windows { + if window.label.trim().is_empty() || window.label.len() > MAX_WINDOW_LABEL_LEN { + return Err(invalid(format!( + "accountUsageWindows.label must be non-empty and at most {MAX_WINDOW_LABEL_LEN} bytes" + ))); + } + if !window.used_percent.is_finite() || window.used_percent < 0.0 { + return Err(ObserverPayloadError::InvalidPayload(format!( + "accountUsageWindows.usedPercent must be finite and non-negative (got {})", + window.used_percent + ))); + } + if let Some(reset_at) = window.reset_at.as_deref() { + check_rfc3339(reset_at, "accountUsageWindows.resetAt")?; + } + } + + if let Some(pricing) = &self.pricing_identity { + if !matches!( + pricing.authority.as_str(), + "api.anthropic.com" | "api.openai.com" | "openrouter.ai" + ) { + return Err(invalid("pricingIdentity.authority is not registered")); + } + check_optional_text( + Some(&pricing.model), + "pricingIdentity.model", + MAX_IDENTIFIER_LEN, + )?; + check_optional_text( + pricing.cache_class.as_deref(), + "pricingIdentity.cacheClass", + MAX_IDENTIFIER_LEN, + )?; + } + + let has_observation = self.turn.as_ref().is_some_and(counts_have_observation) + || self + .cumulative + .as_ref() + .is_some_and(counts_have_observation) + || self.context_used_tokens.is_some() + || !self.account_usage_windows.is_empty(); + if !has_observation { + return Err(invalid( + "agent turn metric must contain observed usage, context, or account windows", + )); + } Ok(()) } } @@ -193,9 +363,8 @@ impl AgentTurnMetricPayload { /// Encrypt an [`AgentTurnMetricPayload`] into a NIP-44 v2 ciphertext string /// using the agent's key pair and the owner's public key. /// -/// Returns `Err(ObserverPayloadError::InvalidPayload)` if any `cost_usd` field -/// is negative or non-finite (NaN/inf), in accordance with NIP-AM §Numeric -/// validity. +/// Returns `Err(ObserverPayloadError::InvalidPayload)` when the payload fails +/// the complete NIP-AM validation contract. /// /// This is the content field of a `kind:44200` event. pub fn encrypt_agent_turn_metric( @@ -212,17 +381,63 @@ pub fn encrypt_agent_turn_metric( /// `recipient_keys` is the owner's key pair. /// /// Returns `Err(ObserverPayloadError::InvalidPayload)` if the decrypted payload -/// fails numeric validation (e.g. negative or non-finite `costUsd`), mirroring -/// the fail-closed contract of [`encrypt_agent_turn_metric`]. +/// fails the complete validation contract, mirroring the fail-closed behavior +/// of [`encrypt_agent_turn_metric`]. pub fn decrypt_agent_turn_metric( recipient_keys: &Keys, event: &Event, ) -> Result { + validate_agent_turn_metric_envelope(recipient_keys, event)?; let payload: AgentTurnMetricPayload = decrypt_observer_payload(recipient_keys, event)?; payload.validate()?; Ok(payload) } +/// Validate the signed NIP-AM event envelope before attempting decryption. +pub fn validate_agent_turn_metric_envelope( + recipient_keys: &Keys, + event: &Event, +) -> Result<(), ObserverPayloadError> { + let invalid = |message: &str| ObserverPayloadError::InvalidPayload(message.to_string()); + if event.kind.as_u16() != 44_200 { + return Err(invalid("event kind must be 44200")); + } + if !event.verify_id() || !event.verify_signature() { + return Err(invalid("event id and signature must be valid")); + } + + let mut owner_tag: Option<&str> = None; + let mut agent_tag: Option<&str> = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + let Some(name) = parts.first().map(String::as_str) else { + continue; + }; + match name { + "h" => return Err(invalid("h tags are forbidden")), + "p" if parts.len() != 2 || owner_tag.replace(parts[1].as_str()).is_some() => { + return Err(invalid("exactly one two-element p tag is required")); + } + "p" => {} + "agent" if parts.len() != 2 || agent_tag.replace(parts[1].as_str()).is_some() => { + return Err(invalid("exactly one two-element agent tag is required")); + } + "agent" => {} + _ => {} + } + } + + let owner_tag = owner_tag.ok_or_else(|| invalid("exactly one p tag is required"))?; + let agent_tag = agent_tag.ok_or_else(|| invalid("exactly one agent tag is required"))?; + if owner_tag != recipient_keys.public_key().to_hex() { + return Err(invalid("p tag must identify the decrypting owner")); + } + if agent_tag != event.pubkey.to_hex() { + return Err(invalid("agent tag must equal the event pubkey")); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -233,6 +448,7 @@ mod tests { harness: "goose".to_string(), model: Some("claude-sonnet-4-5".to_string()), channel_id: Some("12345678-1234-1234-1234-123456789abc".to_string()), + thread_root_id: None, session_id: Some("sess-abc".to_string()), turn_id: Some("turn-1".to_string()), turn_seq: Some(1), @@ -255,6 +471,9 @@ mod tests { }), delta_reliable: true, stop_reason: Some(StopReason::EndTurn), + context_used_tokens: None, + context_limit_tokens: None, + account_usage_windows: Vec::new(), pricing_identity: None, } } @@ -282,6 +501,53 @@ mod tests { assert_eq!(decoded, payload); } + #[test] + fn decrypt_rejects_noncanonical_nip_am_envelopes() { + let agent_keys = Keys::generate(); + let other_agent = Keys::generate(); + let owner_keys = Keys::generate(); + let wrong_owner = Keys::generate(); + let payload = sample_payload(); + let ciphertext = encrypt_agent_turn_metric(&agent_keys, &owner_keys.public_key(), &payload) + .expect("encrypt"); + let owner = owner_keys.public_key().to_hex(); + let agent = agent_keys.public_key().to_hex(); + let other_agent = other_agent.public_key().to_hex(); + let wrong_owner = wrong_owner.public_key().to_hex(); + let tag = |parts: &[&str]| Tag::parse(parts.iter().copied()).expect("tag"); + let cases = vec![ + vec![tag(&["p", &owner])], + vec![tag(&["agent", &agent])], + vec![ + tag(&["p", &owner]), + tag(&["p", &owner]), + tag(&["agent", &agent]), + ], + vec![ + tag(&["p", &owner]), + tag(&["agent", &agent]), + tag(&["agent", &agent]), + ], + vec![tag(&["p", &owner, "extra"]), tag(&["agent", &agent])], + vec![tag(&["p", &owner]), tag(&["agent", &agent, "extra"])], + vec![tag(&["p", &wrong_owner]), tag(&["agent", &agent])], + vec![tag(&["p", &owner]), tag(&["agent", &other_agent])], + vec![ + tag(&["p", &owner]), + tag(&["agent", &agent]), + tag(&["h", "channel"]), + ], + ]; + + for tags in cases { + let event = EventBuilder::new(Kind::Custom(44200), ciphertext.clone()) + .tags(tags) + .sign_with_keys(&agent_keys) + .expect("sign"); + assert!(decrypt_agent_turn_metric(&owner_keys, &event).is_err()); + } + } + #[test] fn wrong_key_decrypt_fails() { let agent_keys = Keys::generate(); @@ -314,6 +580,59 @@ mod tests { ); } + #[test] + fn context_snapshot_and_thread_scope_use_optional_camel_case_fields() { + let json = r#"{ + "harness":"goose", + "timestamp":"2026-07-01T20:11:03Z", + "contextUsedTokens":210000, + "contextLimitTokens":200000, + "accountUsageWindows":[ + {"label":"Session","usedPercent":12.5,"resetAt":"2026-07-02T01:00:00Z"}, + {"label":"Weekly","usedPercent":41.0} + ], + "threadRootId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }"#; + let payload: AgentTurnMetricPayload = serde_json::from_str(json).expect("parse"); + + assert_eq!(payload.context_used_tokens, Some(210_000)); + assert_eq!(payload.context_limit_tokens, Some(200_000)); + assert_eq!(payload.account_usage_windows.len(), 2); + assert_eq!(payload.account_usage_windows[0].label, "Session"); + assert_eq!(payload.account_usage_windows[0].used_percent, 12.5); + assert_eq!( + payload.account_usage_windows[0].reset_at.as_deref(), + Some("2026-07-02T01:00:00Z") + ); + assert_eq!( + payload.thread_root_id.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); + + let encoded = serde_json::to_value(payload).expect("serialize"); + assert_eq!(encoded["contextUsedTokens"], 210_000); + assert_eq!(encoded["contextLimitTokens"], 200_000); + assert_eq!( + encoded["threadRootId"], + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } + + #[test] + fn legacy_payload_omits_new_optional_context_and_thread_fields() { + let json = r#"{"harness":"goose","timestamp":"2026-07-01T20:11:03Z"}"#; + let payload: AgentTurnMetricPayload = serde_json::from_str(json).expect("parse"); + + assert_eq!(payload.context_used_tokens, None); + assert_eq!(payload.context_limit_tokens, None); + assert_eq!(payload.thread_root_id, None); + + let encoded = serde_json::to_value(payload).expect("serialize"); + assert!(encoded.get("contextUsedTokens").is_none()); + assert!(encoded.get("contextLimitTokens").is_none()); + assert!(encoded.get("threadRootId").is_none()); + } + #[test] fn stop_reason_round_trips() { for (variant, json_val) in [ @@ -386,6 +705,7 @@ mod tests { harness: "test".to_string(), model: None, channel_id: None, + thread_root_id: None, session_id: None, turn_id: None, turn_seq: None, @@ -401,6 +721,9 @@ mod tests { cumulative: None, delta_reliable: true, stop_reason: None, + context_used_tokens: None, + context_limit_tokens: None, + account_usage_windows: Vec::new(), pricing_identity: None, } } @@ -410,6 +733,7 @@ mod tests { harness: "test".to_string(), model: None, channel_id: None, + thread_root_id: None, session_id: None, turn_id: None, turn_seq: None, @@ -425,6 +749,9 @@ mod tests { }), delta_reliable: true, stop_reason: None, + context_used_tokens: None, + context_limit_tokens: None, + account_usage_windows: Vec::new(), pricing_identity: None, } } @@ -507,6 +834,101 @@ mod tests { ); } + #[test] + fn validate_enforces_structural_nip_am_contract() { + let mut cases = Vec::new(); + + let mut payload = sample_payload(); + payload.harness = " ".to_string(); + cases.push(("empty harness", payload)); + + let mut payload = sample_payload(); + payload.timestamp = "not-a-time".to_string(); + cases.push(("invalid timestamp", payload)); + + let mut payload = sample_payload(); + payload.session_id = None; + cases.push(("cumulative without session", payload)); + + let mut payload = sample_payload(); + payload.context_used_tokens = Some(10); + payload.context_limit_tokens = None; + cases.push(("one-sided context", payload)); + + let mut payload = sample_payload(); + payload.context_used_tokens = Some(0); + payload.context_limit_tokens = Some(100); + cases.push(("zero context", payload)); + + let mut payload = sample_payload(); + payload.channel_id = None; + payload.thread_root_id = Some("a".repeat(64)); + cases.push(("thread without channel", payload)); + + let mut payload = sample_payload(); + payload.account_usage_windows = vec![AccountUsageWindow { + label: "Session".to_string(), + used_percent: 10.0, + reset_at: Some("tomorrow".to_string()), + }]; + cases.push(("invalid reset time", payload)); + + let mut payload = sample_payload(); + payload.account_usage_windows = vec![AccountUsageWindow { + label: " ".to_string(), + used_percent: 10.0, + reset_at: None, + }]; + cases.push(("empty window label", payload)); + + let mut payload = sample_payload(); + payload.account_usage_windows = (0..65) + .map(|index| AccountUsageWindow { + label: format!("Window {index}"), + used_percent: 10.0, + reset_at: None, + }) + .collect(); + cases.push(("too many windows", payload)); + + let mut payload = sample_payload(); + payload.pricing_identity = Some(PricingIdentity { + authority: "example.com".to_string(), + model: "model".to_string(), + cache_class: None, + }); + cases.push(("unregistered pricing authority", payload)); + + let mut payload = sample_payload(); + payload.turn = None; + payload.cumulative = None; + payload.session_id = None; + payload.turn_seq = None; + payload.context_used_tokens = None; + payload.context_limit_tokens = None; + payload.account_usage_windows.clear(); + cases.push(("informationless snapshot", payload)); + + for (label, payload) in cases { + assert!(payload.validate().is_err(), "{label} must be rejected"); + } + } + + #[test] + fn validate_accepts_quota_only_snapshot() { + let mut payload = sample_payload(); + payload.turn = None; + payload.cumulative = None; + payload.session_id = None; + payload.turn_seq = None; + payload.account_usage_windows = vec![AccountUsageWindow { + label: "Weekly".to_string(), + used_percent: 42.0, + reset_at: Some("2026-07-02T01:00:00Z".to_string()), + }]; + assert!(payload.validate().is_ok()); + } + #[test] fn decrypt_agent_turn_metric_rejects_negative_cost_bypassing_encrypt() { // Regression: a raw/misbehaving agent can persist a syntactically valid diff --git a/desktop/src-tauri/src/archive/agent_usage.rs b/desktop/src-tauri/src/archive/agent_usage.rs index 587970aac65..0f6b7fb7ec5 100644 --- a/desktop/src-tauri/src/archive/agent_usage.rs +++ b/desktop/src-tauri/src/archive/agent_usage.rs @@ -15,6 +15,8 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; +use buzz_core_pkg::agent_turn_metric::{AccountUsageWindow, AgentTurnMetricPayload}; + use super::metric_store::AgentMetricIndexRow; // ── Request ────────────────────────────────────────────────────────────────── @@ -32,6 +34,36 @@ pub struct AgentUsageSeriesRequest { pub agent_pubkey: Option, } +/// Request for the latest durable NIP-AM snapshot per agent. An omitted +/// filter returns every archived agent; an explicit empty list returns none. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LatestAgentMetricSnapshotsRequest { + pub agent_pubkeys: Option>, + /// Optional exact channel scope for context-window fields. Provider + /// account windows remain agent-wide. + pub channel_id: Option, + /// Optional exact Nostr thread-root event id. Requires `channel_id`. + pub thread_root_id: Option, +} + +/// Latest valid decrypted NIP-AM status payload for one agent. Full-range +/// token counters cross IPC as decimal strings to avoid JavaScript precision +/// loss. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LatestAgentMetricSnapshot { + pub agent_pubkey: String, + pub context_used_tokens: Option, + pub context_limit_tokens: Option, + pub context_timestamp: Option, + pub account_usage_windows: Vec, + pub account_usage_windows_timestamp: Option, + pub timestamp: String, + pub model: Option, + pub harness: String, +} + /// Widest interval NIP-AM query validation admits (A9): wide enough to admit /// every real civil-day transition (ordinary DST, 30-minute-offset zones, /// historical calendar skips) while still rejecting arbitrary bins. @@ -48,6 +80,19 @@ const MAX_BOUNDARIES: usize = 367; /// 1 bucket, the `1d` case. const MIN_BOUNDARIES: usize = 2; +/// Maximum number of independently configured agents returned by one latest- +/// snapshot request. This matches the desktop adapter and bounds archive work +/// even for direct IPC callers. +pub(super) const MAX_LATEST_SNAPSHOT_AGENTS: usize = 64; + +/// Validate one agent pubkey and normalize it for indexed lookup. +fn normalize_agent_pubkey(pk: &str) -> Result { + if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("agent_pubkey must be exactly 64 hex characters".to_string()); + } + Ok(pk.to_lowercase()) +} + /// Validate a request per the frozen contract + A9 (drops the 23–25h band /// for a `> 0 && <= 48h` sanity band) and A13 pubkey normalization. /// @@ -84,17 +129,96 @@ pub(super) fn validate_request(req: &AgentUsageSeriesRequest) -> Result None, - Some(pk) => { - if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { - return Err("agent_pubkey must be exactly 64 hex characters".to_string()); + req.agent_pubkey + .as_deref() + .map(normalize_agent_pubkey) + .transpose() +} + +/// Validate, normalize, and deduplicate the optional latest-snapshot filter. +#[derive(Debug, Clone)] +pub(super) struct LatestSnapshotContextScope { + channel_id: String, + thread_root_id: Option, +} + +impl LatestSnapshotContextScope { + pub(super) fn matches(&self, payload: &AgentTurnMetricPayload) -> bool { + payload.channel_id.as_deref() == Some(self.channel_id.as_str()) + && payload.thread_root_id.as_deref() == self.thread_root_id.as_deref() + } +} + +pub(super) fn validate_snapshot_request( + req: &LatestAgentMetricSnapshotsRequest, +) -> Result<(Option>, Option), String> { + if req + .agent_pubkeys + .as_ref() + .is_some_and(|pubkeys| pubkeys.len() > MAX_LATEST_SNAPSHOT_AGENTS) + { + return Err(format!( + "agentPubkeys must contain at most {MAX_LATEST_SNAPSHOT_AGENTS} entries" + )); + } + let agent_pubkeys = req + .agent_pubkeys + .as_ref() + .map(|pubkeys| { + pubkeys + .iter() + .map(|pk| normalize_agent_pubkey(pk)) + .collect() + }) + .transpose()?; + + if req.thread_root_id.is_some() && req.channel_id.is_none() { + return Err("threadRootId requires channelId".to_string()); + } + let scope = req + .channel_id + .as_ref() + .map(|channel_id| { + if channel_id.is_empty() || channel_id.len() > 256 { + return Err("channelId must contain 1 to 256 characters".to_string()); } - Some(pk.to_lowercase()) - } - }; + if let Some(thread_root_id) = &req.thread_root_id { + if thread_root_id.len() != 64 + || !thread_root_id.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err("threadRootId must be exactly 64 hex characters".to_string()); + } + } + Ok(LatestSnapshotContextScope { + channel_id: channel_id.clone(), + thread_root_id: req.thread_root_id.clone(), + }) + }) + .transpose()?; + + Ok((agent_pubkeys, scope)) +} - Ok(normalized_pubkey) +pub(super) fn latest_snapshot_from_payload( + agent_pubkey: String, + payload: AgentTurnMetricPayload, +) -> LatestAgentMetricSnapshot { + let context_timestamp = (payload.context_used_tokens.is_some() + && payload.context_limit_tokens.is_some()) + .then(|| payload.timestamp.clone()); + let account_usage_windows_timestamp = + (!payload.account_usage_windows.is_empty()).then(|| payload.timestamp.clone()); + LatestAgentMetricSnapshot { + agent_pubkey, + context_used_tokens: payload.context_used_tokens.map(|value| value.to_string()), + context_limit_tokens: payload.context_limit_tokens.map(|value| value.to_string()), + context_timestamp, + account_usage_windows: payload.account_usage_windows, + account_usage_windows_timestamp, + timestamp: payload.timestamp, + model: payload.model, + harness: payload.harness, + } } // ── Wire types ─────────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/archive/metric_store.rs b/desktop/src-tauri/src/archive/metric_store.rs index 78363223063..3228a57d066 100644 --- a/desktop/src-tauri/src/archive/metric_store.rs +++ b/desktop/src-tauri/src/archive/metric_store.rs @@ -15,6 +15,8 @@ use rusqlite::{params, Connection, OptionalExtension}; use buzz_core_pkg::agent_turn_metric::AgentTurnMetricPayload; +use super::agent_usage::MAX_LATEST_SNAPSHOT_AGENTS; + // ── u64-safe sortable encoding ─────────────────────────────────────────────── /// Fixed-width digit count for the lexicographically order-preserving decimal @@ -618,6 +620,120 @@ pub(super) fn has_archived_evidence( Ok(exists.is_some()) } +/// Plaintext payload candidate for the latest-snapshot query. Rows are ordered +/// newest-first within each agent so the caller can skip a corrupt candidate +/// and fall back to the next valid archived payload. +pub(super) struct AgentMetricPayloadCandidate { + pub agent_pubkey: String, + pub raw_json: String, +} + +/// Newest payloads retained as corruption fallback for each agent. Rows are +/// validated when indexed; this cushion preserves defensive fallback without +/// allowing one archive to load unbounded plaintext JSON. +const MAX_LATEST_CANDIDATES_PER_AGENT: i64 = 8; + +/// Load valid indexed NIP-AM payloads in newest-first order per agent. The +/// canonical event must still belong to this identity's `owner_p` scope; +/// identity/relay index keys alone are not treated as authorization evidence. +pub(super) fn load_latest_metric_payload_candidates( + conn: &Connection, + identity_pubkey: &str, + relay_url: &str, + agent_pubkeys: Option<&std::collections::HashSet>, +) -> Result, String> { + if agent_pubkeys.is_some_and(std::collections::HashSet::is_empty) { + return Ok(Vec::new()); + } + + let agents: Vec = match agent_pubkeys { + Some(filter) => { + let mut agents: Vec<_> = filter.iter().cloned().collect(); + agents.sort(); + agents + .into_iter() + .take(MAX_LATEST_SNAPSHOT_AGENTS) + .collect() + } + None => { + let mut stmt = conn + .prepare( + "SELECT DISTINCT agent_pubkey + FROM agent_metric_index + WHERE identity_pubkey = ?1 + AND relay_url = ?2 + AND parse_status = 'valid' + ORDER BY agent_pubkey ASC + LIMIT ?3", + ) + .map_err(|e| format!("prepare latest metric agents: {e}"))?; + let rows = stmt + .query_map( + params![ + identity_pubkey, + relay_url, + MAX_LATEST_SNAPSHOT_AGENTS as i64 + ], + |row| row.get(0), + ) + .map_err(|e| format!("query latest metric agents: {e}"))?; + let mut agents = Vec::new(); + for row in rows { + agents.push(row.map_err(|e| format!("read latest metric agent row: {e}"))?); + } + agents + } + }; + + let mut stmt = conn + .prepare( + "SELECT ae.raw_json + FROM agent_metric_index ami + INNER JOIN archived_events ae + ON ae.identity_pubkey = ami.identity_pubkey + AND ae.relay_url = ami.relay_url + AND ae.id = ami.id + WHERE ami.identity_pubkey = ?1 + AND ami.relay_url = ?2 + AND ami.agent_pubkey = ?3 + AND ami.parse_status = 'valid' + AND ae.kind = 44200 + AND EXISTS ( + SELECT 1 FROM archived_event_scopes aes + WHERE aes.identity_pubkey = ami.identity_pubkey + AND aes.relay_url = ami.relay_url + AND aes.id = ami.id + AND aes.scope_type = 'owner_p' + AND aes.scope_value = ?1 + ) + ORDER BY ami.reported_at DESC, ami.id DESC + LIMIT ?4", + ) + .map_err(|e| format!("prepare load_latest_metric_payload_candidates: {e}"))?; + + let mut candidates = Vec::new(); + for agent_pubkey in agents { + let rows = stmt + .query_map( + params![ + identity_pubkey, + relay_url, + agent_pubkey, + MAX_LATEST_CANDIDATES_PER_AGENT + ], + |row| row.get(0), + ) + .map_err(|e| format!("query latest metric payload candidates: {e}"))?; + for row in rows { + candidates.push(AgentMetricPayloadCandidate { + agent_pubkey: agent_pubkey.clone(), + raw_json: row.map_err(|e| format!("read latest metric payload row: {e}"))?, + }); + } + } + Ok(candidates) +} + fn stmt_prepare<'a>(conn: &'a Connection, sql: &str) -> Result, String> { conn.prepare(sql) .map_err(|e| format!("prepare failed: {e} — sql: {sql}")) diff --git a/desktop/src-tauri/src/archive/metric_store_tests.rs b/desktop/src-tauri/src/archive/metric_store_tests.rs index 93fd3f90987..f7f8c365124 100644 --- a/desktop/src-tauri/src/archive/metric_store_tests.rs +++ b/desktop/src-tauri/src/archive/metric_store_tests.rs @@ -46,6 +46,21 @@ fn insert_archived_event( .unwrap(); } +fn insert_metric_candidate( + conn: &Connection, + identity: &str, + relay: &str, + agent: &str, + id: &str, + timestamp: &str, +) { + let json = valid_payload_json(id, 1, timestamp); + insert_archived_event(conn, identity, relay, id, 44200, agent, 100, &json, 200); + store::upsert_event_scope(conn, identity, relay, id, "owner_p", identity, 200).unwrap(); + let row = AgentMetricIndexRow::from_payload(&json, id, agent, 100, 200); + insert_metric_index_row(conn, identity, relay, &row).unwrap(); +} + // ── u64 sortable encoding ──────────────────────────────────────────────────── #[test] @@ -366,6 +381,63 @@ fn has_archived_evidence_ignores_bucket_boundaries() { assert!(has_archived_evidence(&conn, "id", "relay", "agentX").unwrap()); } +#[test] +fn latest_payload_candidates_filter_in_sql_and_bound_each_agent() { + let conn = in_memory(); + let target = format!("{:064x}", 1); + let other = format!("{:064x}", 2); + for i in 0..10 { + insert_metric_candidate( + &conn, + "id", + "relay", + &target, + &format!("target-{i:02}"), + "2026-07-01T00:00:00Z", + ); + } + insert_metric_candidate( + &conn, + "id", + "relay", + &other, + "other", + "2026-07-01T00:00:00Z", + ); + + let filter = std::collections::HashSet::from([target.clone()]); + let loaded = + load_latest_metric_payload_candidates(&conn, "id", "relay", Some(&filter)).unwrap(); + + assert_eq!(loaded.len(), MAX_LATEST_CANDIDATES_PER_AGENT as usize); + assert!(loaded + .iter() + .all(|candidate| candidate.agent_pubkey == target)); +} + +#[test] +fn latest_payload_candidates_bound_unfiltered_agent_count() { + let conn = in_memory(); + for i in 0..=MAX_LATEST_SNAPSHOT_AGENTS { + let agent = format!("{i:064x}"); + insert_metric_candidate( + &conn, + "id", + "relay", + &agent, + &format!("event-{i:02}"), + "2026-07-01T00:00:00Z", + ); + } + + let loaded = load_latest_metric_payload_candidates(&conn, "id", "relay", None).unwrap(); + let agents: std::collections::HashSet<_> = loaded + .iter() + .map(|candidate| candidate.agent_pubkey.as_str()) + .collect(); + assert_eq!(agents.len(), MAX_LATEST_SNAPSHOT_AGENTS); +} + // ── Backfill ────────────────────────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 1b246b3fa23..554ae372476 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -734,6 +734,101 @@ pub async fn read_archived_events( .await } +// ── get_latest_agent_metric_snapshots ──────────────────────────────────────── + +/// Synchronous SQLite core for the latest durable NIP-AM snapshot per agent. +/// Backfill and orphan repair mirror the existing usage-series read path. +fn latest_agent_metric_snapshots( + conn: &Connection, + identity_pk: &str, + relay_url: &str, + request: &agent_usage::LatestAgentMetricSnapshotsRequest, +) -> Result, String> { + use buzz_core_pkg::agent_turn_metric::AgentTurnMetricPayload; + + let (agent_pubkeys, context_scope) = agent_usage::validate_snapshot_request(request)?; + metric_store::backfill_agent_metric_index(conn, identity_pk, relay_url)?; + metric_store::repair_orphaned_metric_index_rows(conn, identity_pk, relay_url)?; + + let candidates = metric_store::load_latest_metric_payload_candidates( + conn, + identity_pk, + relay_url, + agent_pubkeys.as_ref(), + )?; + let mut snapshot_indexes = std::collections::HashMap::::new(); + let mut snapshots = Vec::new(); + for candidate in candidates { + let Ok(payload) = serde_json::from_str::(&candidate.raw_json) + else { + continue; + }; + if payload.validate().is_err() + || chrono::DateTime::parse_from_rfc3339(&payload.timestamp).is_err() + { + continue; + } + let context_matches = context_scope + .as_ref() + .is_none_or(|scope| scope.matches(&payload)); + let payload_timestamp = payload.timestamp.clone(); + + if let Some(&index) = snapshot_indexes.get(&candidate.agent_pubkey) { + let snapshot = &mut snapshots[index]; + if snapshot.context_used_tokens.is_none() + && snapshot.context_limit_tokens.is_none() + && context_matches + && payload.context_used_tokens.is_some() + && payload.context_limit_tokens.is_some() + { + snapshot.context_used_tokens = + payload.context_used_tokens.map(|value| value.to_string()); + snapshot.context_limit_tokens = + payload.context_limit_tokens.map(|value| value.to_string()); + snapshot.context_timestamp = Some(payload_timestamp.clone()); + } + if snapshot.account_usage_windows.is_empty() + && !payload.account_usage_windows.is_empty() + { + snapshot.account_usage_windows = payload.account_usage_windows; + snapshot.account_usage_windows_timestamp = Some(payload_timestamp); + } + continue; + } + + let mut snapshot = + agent_usage::latest_snapshot_from_payload(candidate.agent_pubkey.clone(), payload); + if !context_matches + || snapshot.context_used_tokens.is_none() + || snapshot.context_limit_tokens.is_none() + { + snapshot.context_used_tokens = None; + snapshot.context_limit_tokens = None; + snapshot.context_timestamp = None; + } + snapshot_indexes.insert(candidate.agent_pubkey, snapshots.len()); + snapshots.push(snapshot); + } + Ok(snapshots) +} + +/// Return the newest valid decrypted NIP-AM payload for each requested agent +/// under the active identity + relay `owner_p` archive scope. +#[tauri::command] +pub async fn get_latest_agent_metric_snapshots( + state: State<'_, AppState>, + request: agent_usage::LatestAgentMetricSnapshotsRequest, +) -> Result, String> { + let identity_pk = identity_pubkey(&state)?; + let relay_url = relay_ws_url_with_override(&state); + state + .archive_db + .with_conn(move |conn| { + latest_agent_metric_snapshots(conn, &identity_pk, &relay_url, &request) + }) + .await +} + // ── get_agent_usage_series ─────────────────────────────────────────────────── /// Compute the locally archived NIP-AM usage series for one identity/relay. diff --git a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs index 337dbac922b..76d356d4a82 100644 --- a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs +++ b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs @@ -19,6 +19,7 @@ fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { harness: "test-harness".to_string(), model: Some("test-model".to_string()), channel_id: None, + thread_root_id: None, session_id: Some("sess-1".to_string()), turn_id: Some("turn-1".to_string()), turn_seq: Some(1), @@ -34,6 +35,9 @@ fn make_turn_metric_event(owner_keys: &Keys, agent_keys: &Keys) -> Event { cumulative: None, delta_reliable: true, stop_reason: None, + context_used_tokens: None, + context_limit_tokens: None, + account_usage_windows: Vec::new(), pricing_identity: None, }; let ciphertext = @@ -409,3 +413,339 @@ fn test_agent_usage_series_backfills_unindexed_row_before_reading() { "backfill must index the pre-existing row before the window read" ); } + +// ── get_latest_agent_metric_snapshots integration ──────────────────────────── + +fn insert_metric_snapshot_fixture( + conn: &Connection, + identity: &str, + relay: &str, + agent: &str, + id: &str, + timestamp: &str, + context_used_tokens: Option, + include_usage_windows: bool, +) { + insert_scoped_metric_snapshot_fixture( + conn, + identity, + relay, + agent, + id, + timestamp, + None, + None, + context_used_tokens, + include_usage_windows, + ); +} + +#[allow(clippy::too_many_arguments)] +fn insert_scoped_metric_snapshot_fixture( + conn: &Connection, + identity: &str, + relay: &str, + agent: &str, + id: &str, + timestamp: &str, + channel_id: Option<&str>, + thread_root_id: Option<&str>, + context_used_tokens: Option, + include_usage_windows: bool, +) { + let mut payload = serde_json::json!({ + "harness": format!("harness-{id}"), + "model": format!("model-{id}"), + "timestamp": timestamp, + }); + if let Some(channel_id) = channel_id { + payload["channelId"] = serde_json::json!(channel_id); + } + if let Some(thread_root_id) = thread_root_id { + payload["threadRootId"] = serde_json::json!(thread_root_id); + } + if let Some(used) = context_used_tokens { + payload["contextUsedTokens"] = serde_json::json!(used); + payload["contextLimitTokens"] = serde_json::json!(200_000); + } + if include_usage_windows { + payload["accountUsageWindows"] = serde_json::json!([{ + "label": "Session", + "usedPercent": 12.5, + "resetAt": "2026-07-02T01:00:00Z", + }]); + } + let raw_json = serde_json::to_string(&payload).unwrap(); + store::upsert_archived_event(conn, identity, relay, id, 44200, agent, 100, &raw_json, 200) + .unwrap(); + store::upsert_event_scope(conn, identity, relay, id, "owner_p", identity, 200).unwrap(); + let row = metric_store::AgentMetricIndexRow::from_payload(&raw_json, id, agent, 100, 200); + metric_store::insert_metric_index_row(conn, identity, relay, &row).unwrap(); +} + +#[test] +fn test_latest_agent_metric_snapshots_returns_newest_valid_payload_per_agent() { + let conn = in_memory(); + let identity = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let relay = "wss://relay.example"; + let agent_a = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let agent_b = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + insert_metric_snapshot_fixture( + &conn, + identity, + relay, + agent_a, + "old-a", + "2026-07-01T00:00:00Z", + Some(10_000), + true, + ); + insert_metric_snapshot_fixture( + &conn, + identity, + relay, + agent_a, + "exact-a", + "2026-07-01T01:00:00Z", + Some(25_000), + false, + ); + insert_metric_snapshot_fixture( + &conn, + identity, + relay, + agent_a, + "new-a", + "2026-07-01T02:00:00Z", + None, + true, + ); + insert_metric_snapshot_fixture( + &conn, + identity, + relay, + agent_b, + "only-b", + "2026-07-01T00:30:00Z", + Some(7_500), + true, + ); + + let snapshots = latest_agent_metric_snapshots( + &conn, + identity, + relay, + &agent_usage::LatestAgentMetricSnapshotsRequest::default(), + ) + .unwrap(); + + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].agent_pubkey, agent_a); + assert_eq!(snapshots[0].context_used_tokens.as_deref(), Some("25000")); + assert_eq!(snapshots[0].context_limit_tokens.as_deref(), Some("200000")); + assert_eq!(snapshots[0].timestamp, "2026-07-01T02:00:00Z"); + assert_eq!(snapshots[0].model.as_deref(), Some("model-new-a")); + assert_eq!(snapshots[0].harness, "harness-new-a"); + assert_eq!(snapshots[0].account_usage_windows.len(), 1); + assert_eq!(snapshots[0].account_usage_windows[0].label, "Session"); + assert_eq!(snapshots[1].agent_pubkey, agent_b); +} + +#[test] +fn test_latest_agent_metric_snapshots_filters_agents_and_keeps_owner_scope() { + let conn = in_memory(); + let identity = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other_identity = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + let relay = "wss://relay.example"; + let target = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let other = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + insert_metric_snapshot_fixture( + &conn, + identity, + relay, + target, + "target", + "2026-07-01T01:00:00Z", + Some(10), + true, + ); + insert_metric_snapshot_fixture( + &conn, + identity, + relay, + other, + "other", + "2026-07-01T02:00:00Z", + Some(20), + true, + ); + insert_metric_snapshot_fixture( + &conn, + identity, + relay, + target, + "wrong-owner-scope", + "2026-07-01T04:00:00Z", + Some(40), + true, + ); + conn.execute( + "UPDATE archived_event_scopes SET scope_value = ?1 WHERE id = 'wrong-owner-scope'", + [other_identity], + ) + .unwrap(); + insert_metric_snapshot_fixture( + &conn, + other_identity, + relay, + target, + "foreign-owner", + "2026-07-01T03:00:00Z", + Some(30), + true, + ); + + let snapshots = latest_agent_metric_snapshots( + &conn, + identity, + relay, + &agent_usage::LatestAgentMetricSnapshotsRequest { + agent_pubkeys: Some(vec![target.to_uppercase()]), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].agent_pubkey, target); + assert_eq!(snapshots[0].context_used_tokens.as_deref(), Some("10")); +} + +#[test] +fn test_latest_agent_metric_snapshots_rejects_invalid_agent_filter() { + let conn = in_memory(); + let request = agent_usage::LatestAgentMetricSnapshotsRequest { + agent_pubkeys: Some(vec!["not-a-pubkey".to_string()]), + ..Default::default() + }; + + let error = latest_agent_metric_snapshots(&conn, "identity", "relay", &request).unwrap_err(); + assert!(error.contains("64 hex")); +} + +#[test] +fn test_latest_agent_metric_snapshots_rejects_more_than_64_agents() { + let conn = in_memory(); + let request = agent_usage::LatestAgentMetricSnapshotsRequest { + agent_pubkeys: Some((0..65).map(|i| format!("{i:064x}")).collect()), + ..Default::default() + }; + + let error = latest_agent_metric_snapshots(&conn, "identity", "relay", &request).unwrap_err(); + assert!(error.contains("at most 64")); +} + +#[test] +fn test_latest_agent_metric_snapshots_scopes_context_but_keeps_global_windows() { + let conn = in_memory(); + let identity = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let relay = "wss://relay.example"; + let agent = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let channel_a = "channel-a"; + let channel_b = "channel-b"; + let thread = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + insert_scoped_metric_snapshot_fixture( + &conn, + identity, + relay, + agent, + "channel-a-root", + "2026-07-01T01:00:00Z", + Some(channel_a), + None, + Some(10_000), + false, + ); + insert_scoped_metric_snapshot_fixture( + &conn, + identity, + relay, + agent, + "channel-a-thread", + "2026-07-01T02:00:00Z", + Some(channel_a), + Some(thread), + Some(20_000), + false, + ); + insert_scoped_metric_snapshot_fixture( + &conn, + identity, + relay, + agent, + "channel-b-newer", + "2026-07-01T03:00:00Z", + Some(channel_b), + None, + Some(90_000), + true, + ); + + let root = latest_agent_metric_snapshots( + &conn, + identity, + relay, + &agent_usage::LatestAgentMetricSnapshotsRequest { + agent_pubkeys: Some(vec![agent.to_string()]), + channel_id: Some(channel_a.to_string()), + thread_root_id: None, + }, + ) + .unwrap(); + assert_eq!(root[0].context_used_tokens.as_deref(), Some("10000")); + assert_eq!( + root[0].context_timestamp.as_deref(), + Some("2026-07-01T01:00:00Z") + ); + assert_eq!(root[0].account_usage_windows.len(), 1); + assert_eq!( + root[0].account_usage_windows_timestamp.as_deref(), + Some("2026-07-01T03:00:00Z") + ); + + let scoped_thread = latest_agent_metric_snapshots( + &conn, + identity, + relay, + &agent_usage::LatestAgentMetricSnapshotsRequest { + agent_pubkeys: Some(vec![agent.to_string()]), + channel_id: Some(channel_a.to_string()), + thread_root_id: Some(thread.to_string()), + }, + ) + .unwrap(); + assert_eq!( + scoped_thread[0].context_used_tokens.as_deref(), + Some("20000") + ); + assert_eq!( + scoped_thread[0].context_timestamp.as_deref(), + Some("2026-07-01T02:00:00Z") + ); +} + +#[test] +fn test_latest_agent_metric_snapshots_rejects_thread_without_channel() { + let request = agent_usage::LatestAgentMetricSnapshotsRequest { + agent_pubkeys: None, + channel_id: None, + thread_root_id: Some( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc".to_string(), + ), + }; + let error = agent_usage::validate_snapshot_request(&request).unwrap_err(); + assert!(error.contains("threadRootId requires channelId")); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12082a2a82e..95d6d9e991b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -853,6 +853,7 @@ pub fn run() { archive::read_archived_observer_events_for_channel, archive::index_observer_channel_id, archive::read_unindexed_observer_rows, + archive::get_latest_agent_metric_snapshots, archive::get_agent_usage_series, archive::get_observer_retention_days, archive::set_observer_retention_days, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b4c2039023e..f40b5bb8168 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -53,6 +53,7 @@ import { usePersonaSync } from "@/features/agents/lib/usePersonaSync"; import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion"; import { AgentManagementDialogs } from "@/features/agents/ui/AgentManagementDialogs"; import { RequestedAgentCreateDialogs } from "@/features/agents/ui/RequestedAgentCreateDialogs"; + import { usePresenceSession, usePresenceSubscription, @@ -929,6 +930,7 @@ export function AppShell() { onUnstarChannel={unstarChannel} /> ) : null} + diff --git a/desktop/src/features/agents/status/AgentStatusDisplay.test.mjs b/desktop/src/features/agents/status/AgentStatusDisplay.test.mjs new file mode 100644 index 00000000000..0cf78798259 --- /dev/null +++ b/desktop/src/features/agents/status/AgentStatusDisplay.test.mjs @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { + AgentStatusDetails, + AgentStatusIndicator, + AgentStatusSidebarPanel, +} from "./AgentStatusDisplay.tsx"; +import { deriveConfiguredAgentStatuses } from "./agentStatusModel.ts"; +import { + decodeLatestAgentMetricSnapshot, + selectAgentStatusPubkeys, +} from "./useAgentStatusAdapter.ts"; + +const NOW_SECONDS = 1_789_000_000; +const AGENT_ALPHA_PUBKEY = "a".repeat(64); +const AGENT_BETA_PUBKEY = "b".repeat(64); + +const agents = [ + { id: "agent-alpha", label: "Agent Alpha", agentPubkey: AGENT_ALPHA_PUBKEY }, + { id: "agent-beta", label: "Agent Beta", agentPubkey: AGENT_BETA_PUBKEY }, +]; + +function snapshot(overrides = {}) { + return { + agentPubkey: AGENT_ALPHA_PUBKEY, + model: "model-alpha", + harness: "runtime-alpha", + contextUsedTokens: 75_000n, + contextLimitTokens: 100_000n, + contextTimestamp: null, + accountUsageWindows: [], + accountUsageWindowsTimestamp: null, + timestamp: NOW_SECONDS - 120, + ...overrides, + }; +} + +describe("configured agent status model", () => { + it("keeps an explicitly requested DM agent when no snapshot exists", () => { + assert.deepEqual(selectAgentStatusPubkeys([], AGENT_ALPHA_PUBKEY), [ + AGENT_ALPHA_PUBKEY, + ]); + }); + it("limits the sidebar to configured agents and excludes unknown publishers", () => { + const configured = Array.from({ length: 70 }, (_, index) => + index.toString(16).padStart(64, "0"), + ); + const unknownSnapshot = snapshot({ agentPubkey: "f".repeat(64) }); + + const selected = selectAgentStatusPubkeys( + [unknownSnapshot], + undefined, + configured, + ); + + assert.equal(selected.length, 64); + assert.deepEqual(selected, configured.slice(0, 64)); + assert.equal(selected.includes(unknownSnapshot.agentPubkey), false); + }); + + it("decodes RFC3339 windows and preserves u64 token precision", () => { + const decoded = decodeLatestAgentMetricSnapshot({ + agentPubkey: AGENT_ALPHA_PUBKEY, + model: "gpt-5.6-sol", + harness: "hermes-agent", + contextUsedTokens: "18446744073709551615", + contextLimitTokens: "18446744073709551615", + contextTimestamp: "2026-09-05T10:59:00Z", + accountUsageWindows: [ + { + label: "Session", + usedPercent: 42, + resetAt: "2026-09-05T12:00:00Z", + }, + ], + accountUsageWindowsTimestamp: "2026-09-05T10:58:00Z", + timestamp: "2026-09-05T11:00:00Z", + }); + + assert.equal(decoded.contextUsedTokens, 18_446_744_073_709_551_615n); + assert.equal(decoded.contextLimitTokens, 18_446_744_073_709_551_615n); + assert.equal(decoded.accountUsageWindows[0].resetAt, 1_788_609_600); + assert.equal(decoded.contextTimestamp, 1_788_605_940); + assert.equal(decoded.accountUsageWindowsTimestamp, 1_788_605_880); + assert.equal(decoded.timestamp, 1_788_606_000); + }); + + it("matches exact pubkeys and keeps only the newest snapshot", () => { + const statuses = deriveConfiguredAgentStatuses({ + agents, + nowSeconds: NOW_SECONDS, + snapshots: [ + snapshot({ contextUsedTokens: 20_000n, timestamp: NOW_SECONDS - 300 }), + snapshot(), + snapshot({ agentPubkey: "c".repeat(64), contextUsedTokens: 99_000n }), + ], + }); + + assert.equal(statuses[0].state, "ready"); + assert.equal(statuses[0].contextPercent, 75); + assert.equal(statuses[0].ageSeconds, 120); + assert.equal(statuses[1].state, "empty"); + }); + + it("clamps malformed percentages and marks old snapshots stale", () => { + const [status] = deriveConfiguredAgentStatuses({ + agents: [agents[0]], + nowSeconds: NOW_SECONDS, + snapshots: [ + snapshot({ + contextUsedTokens: 120_000n, + accountUsageWindows: [ + { label: "5 hour", usedPercent: -4 }, + { label: "Weekly", usedPercent: 140 }, + ], + timestamp: NOW_SECONDS - 901, + }), + ], + staleAfterSeconds: 900, + }); + + assert.equal(status.state, "stale"); + assert.equal(status.contextPercent, 100); + assert.deepEqual( + status.usageWindows.map(({ usedPercent }) => usedPercent), + [0, 100], + ); + }); + + it("uses the dominant metric field timestamp for staleness", () => { + const [staleContext, freshAllowance] = deriveConfiguredAgentStatuses({ + agents, + nowSeconds: NOW_SECONDS, + snapshots: [ + snapshot({ + contextUsedTokens: 90_000n, + contextTimestamp: NOW_SECONDS - 901, + accountUsageWindows: [{ label: "Session", usedPercent: 20 }], + accountUsageWindowsTimestamp: NOW_SECONDS - 10, + timestamp: NOW_SECONDS - 10, + }), + snapshot({ + agentPubkey: AGENT_BETA_PUBKEY, + contextUsedTokens: 10_000n, + contextTimestamp: NOW_SECONDS - 901, + accountUsageWindows: [{ label: "Session", usedPercent: 80 }], + accountUsageWindowsTimestamp: NOW_SECONDS - 10, + timestamp: NOW_SECONDS - 10, + }), + ], + staleAfterSeconds: 900, + }); + + assert.equal(staleContext.state, "stale"); + assert.equal(staleContext.ageSeconds, 901); + assert.equal(freshAllowance.state, "ready"); + assert.equal(freshAllowance.ageSeconds, 10); + }); + + it("keeps source errors distinct from missing and unconfigured data", () => { + const statuses = deriveConfiguredAgentStatuses({ + agents: [agents[0], { id: "unconfigured", label: "Unconfigured agent" }], + error: "Metrics unavailable", + nowSeconds: NOW_SECONDS, + snapshots: [], + }); + + assert.equal(statuses[0].state, "error"); + assert.equal(statuses[0].errorMessage, "Metrics unavailable"); + assert.equal(statuses[1].state, "unconfigured"); + }); +}); + +describe("agent status surfaces", () => { + const statuses = deriveConfiguredAgentStatuses({ + agents, + nowSeconds: NOW_SECONDS, + snapshots: [ + snapshot({ + accountUsageWindows: [ + { + label: "5 hour", + usedPercent: 38, + resetAt: NOW_SECONDS + 3_600, + }, + { label: "Weekly", usedPercent: 72 }, + ], + }), + snapshot({ + agentPubkey: AGENT_BETA_PUBKEY, + contextUsedTokens: 10_000n, + contextLimitTokens: 200_000n, + model: "sonnet-4", + }), + ], + }); + + it("renders a compact percentage ring and full on-demand details", () => { + const indicator = renderToStaticMarkup( + AgentStatusIndicator({ status: statuses[0] }), + ); + const details = renderToStaticMarkup( + AgentStatusDetails({ status: statuses[0], nowSeconds: NOW_SECONDS }), + ); + + assert.match(indicator, /aria-label="Agent Alpha usage: 75%"/); + assert.match(indicator, />75% { + const [status] = deriveConfiguredAgentStatuses({ + agents: [agents[0]], + nowSeconds: NOW_SECONDS, + snapshots: [ + snapshot({ + accountUsageWindows: [{ label: "Session", usedPercent: 87 }], + }), + ], + }); + const indicator = renderToStaticMarkup(AgentStatusIndicator({ status })); + + assert.match(indicator, /aria-label="Agent Alpha usage: 87%"/); + }); + + it("lists agent names compactly instead of rendering permanent metric cards", () => { + const desktop = renderToStaticMarkup( + AgentStatusSidebarPanel({ statuses, nowSeconds: NOW_SECONDS }), + ); + + assert.match(desktop, /aria-label="Agent usage"/); + assert.match(desktop, /Agent Alpha/); + assert.match(desktop, /Agent Beta/); + assert.doesNotMatch(desktop, /role="progressbar"/); + assert.doesNotMatch(desktop, /Context unavailable/); + }); + + it("renders honest unavailable states without invented metric values", () => { + const emptyStatuses = deriveConfiguredAgentStatuses({ + agents, + nowSeconds: NOW_SECONDS, + snapshots: [], + }); + const markup = renderToStaticMarkup( + AgentStatusIndicator({ status: emptyStatuses[0] }), + ); + + assert.match(markup, /aria-label="Agent Alpha usage unavailable"/); + assert.doesNotMatch(markup, /0%/); + }); + + it("uses warning colors only at the 70 and 90 percent thresholds", () => { + const [warning] = deriveConfiguredAgentStatuses({ + agents: [agents[0]], + nowSeconds: NOW_SECONDS, + snapshots: [snapshot({ contextUsedTokens: 70_000n })], + }); + const [critical] = deriveConfiguredAgentStatuses({ + agents: [agents[0]], + nowSeconds: NOW_SECONDS, + snapshots: [snapshot({ contextUsedTokens: 90_000n })], + }); + + assert.match( + renderToStaticMarkup(AgentStatusIndicator({ status: warning })), + /text-amber-600/, + ); + assert.match( + renderToStaticMarkup(AgentStatusIndicator({ status: critical })), + /text-destructive/, + ); + }); + + it("keeps long metric lists inside a scrollable detail viewport", () => { + const markup = renderToStaticMarkup( + AgentStatusDetails({ status: statuses[0], nowSeconds: NOW_SECONDS }), + ); + assert.match(markup, /max-h-/); + assert.match(markup, /overflow-y-auto/); + }); +}); diff --git a/desktop/src/features/agents/status/AgentStatusDisplay.tsx b/desktop/src/features/agents/status/AgentStatusDisplay.tsx new file mode 100644 index 00000000000..d7f2111a867 --- /dev/null +++ b/desktop/src/features/agents/status/AgentStatusDisplay.tsx @@ -0,0 +1,349 @@ +import { AlertCircle, Bot, Clock3 } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Progress } from "@/shared/ui/progress"; +import type { AgentStatusViewModel, AgentUsageWindowSnapshot } from "./types"; +import { usePermanentAgentStatuses } from "./useAgentStatusAdapter"; + +function formatPercent(value: number): string { + return `${Math.round(value)}%`; +} + +function formatTokenCount(value: bigint | null): string { + if (value === null) return "—"; + return new Intl.NumberFormat(undefined, { + notation: value >= 10_000n ? "compact" : "standard", + maximumFractionDigits: 1, + }).format(value); +} + +export function formatDataAge(ageSeconds: number | null): string | null { + if (ageSeconds === null) return null; + if (ageSeconds < 60) return "Updated just now"; + if (ageSeconds < 3_600) return `Updated ${Math.floor(ageSeconds / 60)}m ago`; + if (ageSeconds < 86_400) + return `Updated ${Math.floor(ageSeconds / 3_600)}h ago`; + return `Updated ${Math.floor(ageSeconds / 86_400)}d ago`; +} + +export function formatReset( + resetAt: number | null | undefined, + nowSeconds: number, +): string | null { + if (resetAt === null || resetAt === undefined) return null; + const remaining = Math.max(0, resetAt - nowSeconds); + if (remaining < 60) return "Resets now"; + if (remaining < 3_600) return `Resets in ${Math.ceil(remaining / 60)}m`; + if (remaining < 86_400) return `Resets in ${Math.ceil(remaining / 3_600)}h`; + return `Resets in ${Math.ceil(remaining / 86_400)}d`; +} + +function primaryPercent(status: AgentStatusViewModel): number | null { + const knownPercents = status.usageWindows.map((window) => window.usedPercent); + if (status.contextPercent !== null) { + knownPercents.push(status.contextPercent); + } + return knownPercents.length > 0 ? Math.max(...knownPercents) : null; +} + +function MetricBar({ + label, + percent, + detail, +}: { + label: string; + percent: number; + detail?: string | null; +}) { + const toneClass = + percent >= 90 + ? "[&>div]:bg-destructive" + : percent >= 70 + ? "[&>div]:bg-amber-500" + : "[&>div]:bg-muted-foreground"; + return ( +
+
+ {label} + + {formatPercent(percent)} + +
+ + {detail ? ( +
+ {detail} +
+ ) : null} +
+ ); +} + +function StatusMessage({ status }: { status: AgentStatusViewModel }) { + const copy = + status.state === "unconfigured" + ? "Agent identity not configured" + : status.state === "loading" + ? "Loading metrics…" + : status.state === "error" + ? status.errorMessage || "Metrics unavailable" + : "Waiting for metrics"; + const isError = status.state === "error"; + + return ( +
+ {isError ? ( +
+ ); +} + +function UsageWindow({ + window, + nowSeconds, +}: { + window: AgentUsageWindowSnapshot; + nowSeconds: number; +}) { + return ( + + ); +} + +export function AgentStatusIndicator({ + className, + status, +}: { + className?: string; + status: AgentStatusViewModel; +}) { + const percent = primaryPercent(status); + const toneClass = + status.state === "error" || (percent !== null && percent >= 90) + ? "text-destructive" + : status.state === "stale" || (percent !== null && percent >= 70) + ? "text-amber-600 dark:text-amber-400" + : "text-muted-foreground"; + const label = + percent === null + ? `${status.label} usage unavailable` + : `${status.label} usage: ${formatPercent(percent)}`; + + return ( + + + {percent === null ? "—" : formatPercent(percent)} + + ); +} + +export function AgentStatusDetails({ + nowSeconds = Math.floor(Date.now() / 1_000), + status, +}: { + nowSeconds?: number; + status: AgentStatusViewModel; +}) { + const hasMetrics = status.state === "ready" || status.state === "stale"; + const age = formatDataAge(status.ageSeconds); + const technicalLabel = [status.model, status.harness] + .filter(Boolean) + .join(" · "); + const usageWindowOccurrences = new Map(); + + return ( +
+
+ {status.label} + {status.state === "stale" ? ( + + Stale + + ) : null} +
+ {hasMetrics ? ( + <> + {status.contextPercent !== null ? ( + + ) : ( +
+ Context unavailable +
+ )} + {status.usageWindows.length > 0 ? ( +
+ Provider limits + {formatDataAge(status.usageWindowsAgeSeconds)} +
+ ) : null} + {status.usageWindows.map((window) => { + const occurrence = usageWindowOccurrences.get(window.label) ?? 0; + usageWindowOccurrences.set(window.label, occurrence + 1); + return ( + + ); + })} +
+ + {technicalLabel || "Unknown runtime"} + + {age ? {age} : null} +
+ + ) : ( + + )} +
+ ); +} + +export function AgentStatusPopover({ + className, + nowSeconds, + status, +}: { + className?: string; + nowSeconds?: number; + status: AgentStatusViewModel; +}) { + return ( + + + + + + + + + ); +} + +export function AgentStatusSidebarPanel({ + statuses, + nowSeconds = Math.floor(Date.now() / 1_000), +}: { + statuses: readonly AgentStatusViewModel[]; + nowSeconds?: number; +}) { + if (statuses.length === 0) return null; + return ( +
+
+
+ {statuses.map((status) => ( +
+ {status.label} + +
+ ))} +
+ ); +} + +export function PermanentAgentStatusSidebarPanel() { + const statuses = usePermanentAgentStatuses(); + return ; +} + +export function AgentStatusForPubkey({ + channelId, + pubkey, + threadRootId, +}: { + channelId?: string; + pubkey?: string; + threadRootId?: string; +}) { + const statuses = usePermanentAgentStatuses({ + agentPubkey: pubkey ?? null, + channelId, + threadRootId, + }); + const normalizedPubkey = pubkey?.trim().toLowerCase(); + const status = normalizedPubkey + ? statuses.find( + (candidate) => + candidate.agentPubkey?.toLowerCase() === normalizedPubkey, + ) + : undefined; + return status ? : null; +} diff --git a/desktop/src/features/agents/status/agentStatusModel.ts b/desktop/src/features/agents/status/agentStatusModel.ts new file mode 100644 index 00000000000..d9e4a61829b --- /dev/null +++ b/desktop/src/features/agents/status/agentStatusModel.ts @@ -0,0 +1,137 @@ +import type { AgentStatusViewModel, AgentMetricSnapshot } from "./types"; + +const DEFAULT_STALE_AFTER_SECONDS = 45 * 60; + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(100, Math.max(0, value)); +} + +function normalizedPubkey(value: string | undefined): string | null { + const candidate = value?.trim().toLowerCase(); + return candidate && /^[0-9a-f]{64}$/.test(candidate) ? candidate : null; +} + +function newestSnapshot( + snapshots: readonly AgentMetricSnapshot[], + pubkey: string, +): AgentMetricSnapshot | null { + let newest: AgentMetricSnapshot | null = null; + for (const snapshot of snapshots) { + if (snapshot.agentPubkey.trim().toLowerCase() !== pubkey) continue; + if (!newest || snapshot.timestamp > newest.timestamp) newest = snapshot; + } + return newest; +} + +export function deriveConfiguredAgentStatuses({ + agents, + snapshots, + isLoading = false, + error = null, + nowSeconds = Math.floor(Date.now() / 1_000), + staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS, +}: { + agents: readonly { agentPubkey: string; id: string; label: string }[]; + snapshots: readonly AgentMetricSnapshot[]; + isLoading?: boolean; + error?: string | null; + nowSeconds?: number; + staleAfterSeconds?: number; +}): AgentStatusViewModel[] { + return agents.map((agent) => { + const pubkey = normalizedPubkey(agent.agentPubkey); + const base = { + id: agent.id, + label: agent.label, + agentPubkey: pubkey ?? undefined, + model: null, + harness: null, + contextUsedTokens: null, + contextLimitTokens: null, + contextPercent: null, + contextAgeSeconds: null, + usageWindows: [], + usageWindowsAgeSeconds: null, + timestamp: null, + ageSeconds: null, + errorMessage: null, + } satisfies Omit; + + if (!pubkey) return { ...base, state: "unconfigured" as const }; + + const snapshot = newestSnapshot(snapshots, pubkey); + if (!snapshot) { + if (error) { + return { ...base, state: "error" as const, errorMessage: error }; + } + return { + ...base, + state: isLoading ? ("loading" as const) : ("empty" as const), + }; + } + + const hasContext = + snapshot.contextUsedTokens !== null && + snapshot.contextLimitTokens !== null && + snapshot.contextLimitTokens > 0n; + const contextPercent = hasContext + ? clampPercent( + Number( + ((snapshot.contextUsedTokens ?? 0n) * 10_000n) / + (snapshot.contextLimitTokens ?? 1n), + ) / 100, + ) + : null; + const usageWindows = snapshot.accountUsageWindows.map((window) => ({ + ...window, + usedPercent: clampPercent(window.usedPercent), + })); + const contextTimestamp = snapshot.contextTimestamp ?? snapshot.timestamp; + const usageWindowsTimestamp = + snapshot.accountUsageWindowsTimestamp ?? snapshot.timestamp; + const contextAgeSeconds = + contextPercent === null + ? null + : Math.max(0, nowSeconds - contextTimestamp); + const usageWindowsAgeSeconds = + usageWindows.length === 0 + ? null + : Math.max(0, nowSeconds - usageWindowsTimestamp); + const primaryCandidates = [ + ...(contextPercent === null + ? [] + : [{ percent: contextPercent, timestamp: contextTimestamp }]), + ...usageWindows.map((window) => ({ + percent: window.usedPercent, + timestamp: usageWindowsTimestamp, + })), + ]; + const primaryMetric = primaryCandidates.reduce< + { percent: number; timestamp: number } | undefined + >( + (highest, candidate) => + !highest || candidate.percent > highest.percent ? candidate : highest, + undefined, + ); + const ageSeconds = Math.max( + 0, + nowSeconds - (primaryMetric?.timestamp ?? snapshot.timestamp), + ); + + return { + ...base, + state: ageSeconds > staleAfterSeconds ? "stale" : "ready", + model: snapshot.model, + harness: snapshot.harness, + contextUsedTokens: snapshot.contextUsedTokens, + contextLimitTokens: snapshot.contextLimitTokens, + contextPercent, + contextAgeSeconds, + usageWindows, + usageWindowsAgeSeconds, + timestamp: snapshot.timestamp, + ageSeconds, + }; + }); +} diff --git a/desktop/src/features/agents/status/types.ts b/desktop/src/features/agents/status/types.ts new file mode 100644 index 00000000000..fa82f2a5389 --- /dev/null +++ b/desktop/src/features/agents/status/types.ts @@ -0,0 +1,51 @@ +export type AgentUsageWindowSnapshot = { + label: string; + usedPercent: number; + resetAt?: number | null; +}; + +/** Wire-neutral NIP-AM shape expected from get_latest_agent_metric_snapshots. */ +export type AgentMetricSnapshot = { + agentPubkey: string; + model: string | null; + harness: string | null; + contextUsedTokens: bigint | null; + contextLimitTokens: bigint | null; + contextTimestamp: number | null; + accountUsageWindows: readonly AgentUsageWindowSnapshot[]; + accountUsageWindowsTimestamp: number | null; + /** NIP-01 Unix timestamp in seconds. */ + timestamp: number; +}; + +export type AgentMetricSnapshotState = { + snapshots: readonly AgentMetricSnapshot[]; + isLoading?: boolean; + error?: string | null; +}; + +export type AgentStatusState = + | "ready" + | "stale" + | "empty" + | "loading" + | "error" + | "unconfigured"; + +export type AgentStatusViewModel = { + id: string; + label: string; + state: AgentStatusState; + agentPubkey?: string; + model: string | null; + harness: string | null; + contextUsedTokens: bigint | null; + contextLimitTokens: bigint | null; + contextPercent: number | null; + contextAgeSeconds: number | null; + usageWindows: readonly AgentUsageWindowSnapshot[]; + usageWindowsAgeSeconds: number | null; + timestamp: number | null; + ageSeconds: number | null; + errorMessage: string | null; +}; diff --git a/desktop/src/features/agents/status/useAgentStatusAdapter.ts b/desktop/src/features/agents/status/useAgentStatusAdapter.ts new file mode 100644 index 00000000000..ca3d7165284 --- /dev/null +++ b/desktop/src/features/agents/status/useAgentStatusAdapter.ts @@ -0,0 +1,187 @@ +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { + getLatestAgentMetricSnapshots, + onAgentMetricsChanged, + type LatestAgentMetricSnapshot, + type LatestAgentMetricSnapshotsRequest, +} from "@/shared/api/tauriArchive"; +import { deriveConfiguredAgentStatuses } from "./agentStatusModel"; +import type { AgentMetricSnapshot, AgentStatusViewModel } from "./types"; + +function parseTokenCount(value: string | null): bigint | null { + if (value === null || !/^[0-9]+$/.test(value)) return null; + try { + return BigInt(value); + } catch { + return null; + } +} + +function parseRfc3339Seconds(value: string): number | null { + const millis = Date.parse(value); + return Number.isFinite(millis) ? Math.floor(millis / 1_000) : null; +} + +/** Convert the exact Tauri wire shape without losing u64 token precision. */ +export function decodeLatestAgentMetricSnapshot( + snapshot: LatestAgentMetricSnapshot, +): AgentMetricSnapshot | null { + const timestamp = parseRfc3339Seconds(snapshot.timestamp); + if (timestamp === null) return null; + return { + agentPubkey: snapshot.agentPubkey, + model: snapshot.model, + harness: snapshot.harness, + contextUsedTokens: parseTokenCount(snapshot.contextUsedTokens), + contextLimitTokens: parseTokenCount(snapshot.contextLimitTokens), + contextTimestamp: snapshot.contextTimestamp + ? parseRfc3339Seconds(snapshot.contextTimestamp) + : null, + accountUsageWindows: snapshot.accountUsageWindows.map((window) => ({ + label: window.label, + usedPercent: window.usedPercent, + resetAt: window.resetAt ? parseRfc3339Seconds(window.resetAt) : null, + })), + accountUsageWindowsTimestamp: snapshot.accountUsageWindowsTimestamp + ? parseRfc3339Seconds(snapshot.accountUsageWindowsTimestamp) + : null, + timestamp, + }; +} + +export function useAgentStatusAdapter( + agents: readonly { agentPubkey: string; id: string; label: string }[], + snapshots: readonly AgentMetricSnapshot[], + isLoading = false, + error: string | null = null, +): AgentStatusViewModel[] { + return React.useMemo( + () => + deriveConfiguredAgentStatuses({ + agents, + error, + isLoading, + snapshots, + }), + [agents, error, isLoading, snapshots], + ); +} + +export function selectAgentStatusPubkeys( + snapshots: readonly AgentMetricSnapshot[], + requestedPubkey?: string | null, + configuredPubkeys?: readonly string[], +): string[] { + const requested = requestedPubkey?.trim().toLowerCase(); + if (requested && /^[0-9a-f]{64}$/.test(requested)) return [requested]; + if (requestedPubkey === null) return []; + const candidates = + configuredPubkeys ?? snapshots.map((snapshot) => snapshot.agentPubkey); + return Array.from( + new Set( + candidates + .map((pubkey) => pubkey.trim().toLowerCase()) + .filter((pubkey) => /^[0-9a-f]{64}$/.test(pubkey)), + ), + ).slice(0, 64); +} + +/** Owner-scoped live query, optionally with exact context-channel scope. */ +export function usePermanentAgentStatuses( + options: { + agentPubkey?: string | null; + channelId?: string; + threadRootId?: string; + } = {}, +): AgentStatusViewModel[] { + const { agentPubkey, channelId, threadRootId } = options; + const managedAgents = useManagedAgentsQuery().data; + const relayAgents = useRelayAgentsQuery().data; + const configuredAgentNames = React.useMemo( + () => + new Map( + [...(managedAgents ?? []), ...(relayAgents ?? [])].map((agent) => [ + agent.pubkey.trim().toLowerCase(), + agent.name, + ]), + ), + [managedAgents, relayAgents], + ); + const configuredPubkeys = React.useMemo( + () => Array.from(configuredAgentNames.keys()), + [configuredAgentNames], + ); + const targetPubkeys = React.useMemo( + () => selectAgentStatusPubkeys([], agentPubkey, configuredPubkeys), + [agentPubkey, configuredPubkeys], + ); + const normalizedRequest = React.useMemo( + () => ({ + agentPubkeys: targetPubkeys, + channelId, + threadRootId, + }), + [channelId, targetPubkeys, threadRootId], + ); + const query = useQuery({ + queryKey: [ + "latest-agent-metric-snapshots", + targetPubkeys, + channelId ?? null, + threadRootId ?? null, + ], + queryFn: async () => + targetPubkeys.length === 0 + ? [] + : (await getLatestAgentMetricSnapshots(normalizedRequest)).flatMap( + (snapshot) => { + const decoded = decodeLatestAgentMetricSnapshot(snapshot); + return decoded ? [decoded] : []; + }, + ), + refetchInterval: 5 * 60 * 1_000, + }); + + React.useEffect( + () => onAgentMetricsChanged(() => void query.refetch()), + [query.refetch], + ); + + const error = query.error + ? query.error instanceof Error + ? query.error.message + : "Metrics unavailable" + : null; + const snapshots = query.data ?? []; + const pubkeys = React.useMemo( + () => selectAgentStatusPubkeys(snapshots, agentPubkey, configuredPubkeys), + [agentPubkey, configuredPubkeys, snapshots], + ); + const profiles = useUsersBatchQuery(pubkeys, { + enabled: pubkeys.length > 0, + }).data?.profiles; + const agents = React.useMemo( + () => + pubkeys.map((pubkey) => ({ + id: pubkey, + label: + configuredAgentNames.get(pubkey) ?? + resolveUserLabel({ + pubkey, + profiles, + }), + agentPubkey: pubkey, + })), + [configuredAgentNames, profiles, pubkeys], + ); + + return useAgentStatusAdapter(agents, snapshots, query.isLoading, error); +} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index f1c5f5ae42d..b705702db38 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -15,6 +15,7 @@ import { scaleProfileAvatarStatusGeometry, } from "@/features/profile/ui/ProfileAvatarWithStatus"; import { AgentManagementMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; +import { AgentStatusForPubkey } from "@/features/agents/status/AgentStatusDisplay"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserNameIndicators } from "@/features/user-status/ui/UserNameIndicators"; import { Button } from "@/shared/ui/button"; @@ -211,11 +212,19 @@ export function ChannelScreenHeader({ title={activeChannelTitle} titleAdornment={ activeChannel?.channelType === "dm" && !isGroupDm ? ( - + <> + + {activeDmParticipant?.isAgent ? ( + + ) : null} + ) : null } transparentChrome={transparentChrome} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 93b6defe487..dd8a4170867 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -76,6 +76,7 @@ import { useSidebar, } from "@/shared/ui/sidebar"; import { useProtectedVisibleDirectMessages } from "@protected-feature-components"; +import { PermanentAgentStatusSidebarPanel } from "@/features/agents/status/AgentStatusDisplay"; export function AppSidebar({ addCommunityPrefill, @@ -832,6 +833,7 @@ export function AppSidebar({ ) : null} + {relayConnectionCard.showSidebarRelayConnectionCard && (isMobile ? openMobile : sidebarOpen) ? ( ("get_agent_usage_series", { request }); } +/** + * Read the newest valid, decrypted, locally archived NIP-AM status payload for + * each agent under the active identity + relay owner scope. + */ +export async function getLatestAgentMetricSnapshots( + request: LatestAgentMetricSnapshotsRequest = {}, +): Promise { + return invokeTauri( + "get_latest_agent_metric_snapshots", + { request }, + ); +} + /** * Read a paginated page of archived raw events for a scope. * diff --git a/docs/nips/NIP-AM.md b/docs/nips/NIP-AM.md index 794a6722d54..927da50c738 100644 --- a/docs/nips/NIP-AM.md +++ b/docs/nips/NIP-AM.md @@ -81,6 +81,7 @@ The `content` field decrypts to a UTF-8 JSON object: "harness": "goose", // REQUIRED: harness identifier "model": "claude-sonnet-4-5", // model id, or null if unknown "channelId": "" | null, + "threadRootId": "<64-char event id>", // omit outside a thread-scoped session "sessionId": "" | null, // REQUIRED when "cumulative" is present "turnId": "" | null, "turnSeq": 17 | null, // REQUIRED when "cumulative" is present @@ -110,6 +111,19 @@ The `content` field decrypts to a UTF-8 JSON object: // "turn" object unreliable for this event. "deltaReliable": true, + // Point-in-time context snapshot from the last successful model request in + // this turn. Omit unknown/zero values. This is not cumulative usage. + "contextUsedTokens": 15392, + "contextLimitTokens": 272000, + + // Optional encrypted provider-account quota gauges. A producer may publish + // whichever windows its authenticated provider exposes. No credential or + // bearer token may be included. + "accountUsageWindows": [ + { "label": "Session", "usedPercent": 12.5, "resetAt": "2026-07-02T01:00:00Z" }, + { "label": "Weekly", "usedPercent": 41.0, "resetAt": "2026-07-07T00:00:00Z" } + ], + // Billing identity, present only when the publisher can prove applicability // from the actual endpoint (official provider API) and the actually-requested // model for the usage represented. Omit this field when applicability cannot @@ -133,6 +147,22 @@ nullable, except as constrained below: `pricingIdentity` is optional but not nullable (omit it entirely rather than set it to null). Consumers MUST ignore unknown fields (forward compatibility). +`harness` MUST be non-empty and at most 128 UTF-8 bytes. Optional model, +channel, session, turn, pricing-model, pricing-cache-class identifiers MUST be +non-empty when present and at most 256 UTF-8 bytes. `threadRootId`, when +present, requires `channelId` and MUST be exactly 64 lowercase hexadecimal +characters. + +`contextUsedTokens` and `contextLimitTokens` describe the input-side context +of the final successful provider request in the turn. Publishers MUST report +both as a pair of positive integers, or omit both when either value is unknown +or zero. `contextUsedTokens` MAY exceed the +limit; consumers clamp progress bars but retain the reported values. +`accountUsageWindows` is a last-write-wins display snapshot scoped to the +publishing agent account. Each `usedPercent` MUST be finite and non-negative; +`resetAt`, when present, is RFC 3339. A payload contains at most 64 windows; +each provider-facing label MUST be non-empty and at most 128 UTF-8 bytes. + ### Ordering and delta recomputation When a `cumulative` object is present, `sessionId` and `turnSeq` are @@ -216,12 +246,35 @@ unlabeled total. ## Publisher Behavior -- Publish exactly one event per completed turn, at turn completion, including - turns that end in cancellation or error when usage was observed. -- Do NOT publish an event for a turn with no observed usage (all counters - unknown); an all-null metric carries no information. +A publisher can be embedded in a local ACP harness or run next to a remote +cloud agent. How it obtains provider quotas is implementation-specific; both +producer paths use the same event kind, tags, encryption, validation, and +payload fields. The event MUST be signed by the agent named by the `agent` tag. +A separate quota collector therefore publishes through the agent identity; it +MUST NOT substitute its own identity or expose provider credentials. + +- Publish one turn metric at turn completion, including turns that end in + cancellation or error when usage was observed. +- A publisher MAY also emit a quota-only snapshot when + `accountUsageWindows` changes, or as a low-frequency heartbeat. Quota-only + snapshots omit unknown turn and context counters rather than inventing + values. They remain agent-account snapshots, not billing records. +- Do NOT publish an event with no observed turn usage and no context or account + snapshot fields; such an event carries no information. - `created_at` SHOULD equal the payload `timestamp` truncated to seconds. +Quota collection and turn instrumentation MAY run in separate processes, but +partial events from both paths are expected to be merged by consumers per +agent. The newest valid context pair (`contextUsedTokens` together with +`contextLimitTokens`) remains available when a newer quota-only event omits it; +the newest non-empty `accountUsageWindows` array replaces the older array. +Context pairs are scoped by the exact (`channelId`, `threadRootId`) tuple: +channel- or thread-specific UI MUST NOT display a context pair from another +scope. `accountUsageWindows` remains agent-account scoped and MAY be combined +with an exact scoped context pair. An omitted `threadRootId` identifies the +channel root and MUST NOT match a thread-scoped pair. Consumers MUST NOT merge +snapshots across different agent pubkeys. + ## Relay Behavior On receiving a kind 44200 event, a relay MUST: @@ -263,6 +316,14 @@ clients MUST use `(sessionId, turnSeq)` from the decrypted payload as described above; `created_at` is suitable only for coarse time-window queries. +Clients MUST bound retained agent and scope state and SHOULD accept metrics +only for agents known through authenticated local configuration or channel +membership. A bounded initial subscription is not sufficient for field-wise +snapshot reconstruction: clients SHOULD page owner- and author-scoped history +backward until the requested context/quota fields are found or the archive is +exhausted, with implementation-defined work limits to resist malicious relay +histories. + ## Relationship to Other NIPs - [NIP-AO](NIP-AO.md): same agent↔owner encryption and tag scoping, but diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index d07cf2425b2..03d0b047ea6 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -12,6 +12,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/animated_avatar.dart'; +import '../../shared/agent_usage/agent_usage_indicator.dart'; import '../../shared/emoji/emoji_burst.dart'; import '../../shared/huddle/huddle.dart'; import '../../shared/mentions/agent_identity_provider.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 5ea4c041d78..08be5909c91 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -248,6 +248,10 @@ class _DmAppBarTitle extends ConsumerWidget { 'away' => 'Away', _ => 'Offline', }; + final displayLabel = resolveDmChannelDisplayLabel( + channel, + currentPubkey: currentPubkey, + ); return Row( children: [ @@ -303,16 +307,32 @@ class _DmAppBarTitle extends ConsumerWidget { children: [ Flexible( child: Text( - resolveDmChannelDisplayLabel( - channel, - currentPubkey: currentPubkey, - ), + displayLabel, maxLines: 1, overflow: TextOverflow.ellipsis, key: const ValueKey('dm-header-name'), style: context.textTheme.titleSmall, ), ), + if (isAgent && otherPubkey != null) ...[ + const SizedBox(width: Grid.half), + AgentUsageIndicator( + agentPubkey: otherPubkey, + agentLabel: displayLabel, + channelId: channel.id, + childDiameter: 16, + child: Icon( + LucideIcons.bot, + size: 12, + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(width: Grid.quarter), + AgentUsagePercentText( + agentPubkey: otherPubkey, + channelId: channel.id, + ), + ], if (channel.isEphemeral) ...[ const SizedBox(width: Grid.quarter), _HeaderEphemeralBadge(channel: channel), diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 568737362f1..05a21df6a84 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/agent_usage/agent_usage_indicator.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -224,15 +225,37 @@ class _MemberTile extends ConsumerWidget { final initial = label.substring(0, 1).toUpperCase(); final showManagementActions = canManage && !isSelf && !member.isOwner; final showMenu = showManagementActions || onViewActivity != null; + final isAgent = member.isBot || profile?.isAgent == true; + + final avatar = _MemberAvatar( + avatarUrl: profile?.avatarUrl, + initial: initial, + isAgent: isAgent, + ); return ListTile( contentPadding: EdgeInsets.zero, - leading: _MemberAvatar( - avatarUrl: profile?.avatarUrl, - initial: initial, - isAgent: member.isBot || profile?.isAgent == true, + leading: isAgent + ? AgentUsageIndicator( + agentPubkey: member.pubkey, + agentLabel: label, + channelId: channelId, + child: avatar, + ) + : avatar, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(label, overflow: TextOverflow.ellipsis)), + if (isAgent) ...[ + const SizedBox(width: Grid.half), + AgentUsagePercentText( + agentPubkey: member.pubkey, + channelId: channelId, + ), + ], + ], ), - title: Text(label), subtitle: isWorking ? Row( mainAxisSize: MainAxisSize.min, diff --git a/mobile/lib/shared/agent_usage/agent_usage_detail_sheet.dart b/mobile/lib/shared/agent_usage/agent_usage_detail_sheet.dart new file mode 100644 index 00000000000..dbd9a215168 --- /dev/null +++ b/mobile/lib/shared/agent_usage/agent_usage_detail_sheet.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; + +import '../widgets/modal_presentation.dart'; +import '../theme/theme.dart'; +import 'agent_usage_models.dart'; + +/// Opens the detail breakdown for one agent's usage snapshot. +Future showAgentUsageDetailSheet({ + required BuildContext context, + required String agentLabel, + required AgentUsageSnapshot? snapshot, + required AgentUsageStatus status, +}) { + return showBuzzModalBottomSheet( + context: context, + title: agentLabel, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + Grid.sm, + ), + child: SingleChildScrollView( + child: _AgentUsageDetailBody(snapshot: snapshot, status: status), + ), + ), + ), + ); +} + +class _AgentUsageDetailBody extends StatelessWidget { + final AgentUsageSnapshot? snapshot; + final AgentUsageStatus status; + + const _AgentUsageDetailBody({required this.snapshot, required this.status}); + + @override + Widget build(BuildContext context) { + final snapshot = this.snapshot; + if (snapshot == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.md), + child: Text( + status == AgentUsageStatus.error + ? 'Usage data unavailable — the live subscription is not connected.' + : 'No usage data reported yet.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StatusLine(snapshot: snapshot, status: status), + const SizedBox(height: Grid.xxs), + if (snapshot.contextUsedTokens != null && + snapshot.contextLimitTokens != null) + _UsageRow( + label: 'Context window', + valueText: + '${_formatTokens(snapshot.contextUsedTokens!)} / ${_formatTokens(snapshot.contextLimitTokens!)}', + fraction: snapshot.contextUsedFraction, + ), + for (final window in snapshot.accountUsageWindows) + _UsageRow( + label: window.label, + valueText: + '${window.usedPercent.toStringAsFixed(0)}%' + '${window.resetAt != null ? ' · resets ${_formatRelative(window.resetAt!)}' : ''}', + fraction: (window.usedPercent / 100).clamp(0.0, 1.0), + ), + if (snapshot.cumulative?.totalTokens != null || + snapshot.cumulative?.costUsd != null) ...[ + const SizedBox(height: Grid.xxs), + Text( + 'Session total: ' + '${snapshot.cumulative?.totalTokens != null ? '${_formatTokens(snapshot.cumulative!.totalTokens!)} tokens' : 'unknown'}' + '${snapshot.cumulative?.costUsd != null ? ' · \$${snapshot.cumulative!.costUsd!.toStringAsFixed(2)}' : ''}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ], + ); + } +} + +class _StatusLine extends StatelessWidget { + final AgentUsageSnapshot snapshot; + final AgentUsageStatus status; + + const _StatusLine({required this.snapshot, required this.status}); + + @override + Widget build(BuildContext context) { + final harnessLine = snapshot.model != null + ? '${snapshot.harness} · ${snapshot.model}' + : snapshot.harness; + final asOf = 'as of ${_formatRelative(snapshot.lastEventAt)}'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + harnessLine, + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + status == AgentUsageStatus.stale + ? '$asOf · may be out of date' + : asOf, + style: context.textTheme.bodySmall?.copyWith( + color: status == AgentUsageStatus.stale + ? context.appColors.warning + : context.colors.onSurfaceVariant, + ), + ), + ], + ); + } +} + +class _UsageRow extends StatelessWidget { + final String label; + final String valueText; + final double? fraction; + + const _UsageRow({ + required this.label, + required this.valueText, + required this.fraction, + }); + + @override + Widget build(BuildContext context) { + final color = fraction == null + ? context.colors.onSurfaceVariant + : fraction! >= 0.9 + ? context.colors.error + : fraction! >= 0.7 + ? context.appColors.warning + : context.colors.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.quarter), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium, + ), + ), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text( + valueText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.end, + style: context.textTheme.bodyMedium?.copyWith(color: color), + ), + ), + ], + ), + if (fraction != null) ...[ + const SizedBox(height: Grid.quarter), + ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: LinearProgressIndicator( + value: fraction, + minHeight: 4, + backgroundColor: color.withValues(alpha: 0.15), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ], + ), + ); + } +} + +String _formatTokens(int tokens) { + if (tokens >= 1000000) { + return '${(tokens / 1000000).toStringAsFixed(1)}M'; + } + if (tokens >= 1000) { + return '${(tokens / 1000).toStringAsFixed(1)}k'; + } + return tokens.toString(); +} + +String _formatRelative(DateTime time) { + final now = DateTime.now(); + final isFuture = time.isAfter(now); + final diff = (isFuture ? time.difference(now) : now.difference(time)); + final String magnitude; + if (diff.inMinutes < 1) { + magnitude = 'moment'; + } else if (diff.inMinutes < 60) { + magnitude = '${diff.inMinutes}m'; + } else if (diff.inHours < 24) { + magnitude = '${diff.inHours}h'; + } else { + magnitude = '${diff.inDays}d'; + } + if (magnitude == 'moment') { + return isFuture ? 'in a moment' : 'just now'; + } + return isFuture ? 'in $magnitude' : '$magnitude ago'; +} diff --git a/mobile/lib/shared/agent_usage/agent_usage_indicator.dart b/mobile/lib/shared/agent_usage/agent_usage_indicator.dart new file mode 100644 index 00000000000..a39a43dd3a2 --- /dev/null +++ b/mobile/lib/shared/agent_usage/agent_usage_indicator.dart @@ -0,0 +1,252 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../theme/theme.dart'; +import 'agent_usage_detail_sheet.dart'; +import 'agent_usage_models.dart'; +import 'agent_usage_subscription.dart'; + +/// Compact, permanent usage ring drawn around an agent's avatar. +/// +/// Shows the agent's most urgent known usage fraction (the highest +/// provider-account quota window, falling back to context-window usage) as a +/// thin colored arc. Tapping opens a detail sheet with the full breakdown. +/// Renders nothing extra (just the plain [child]) when no usage data has +/// ever been observed and the subscription is otherwise healthy — an agent +/// that hasn't reported usage yet is not an error state. +class AgentUsageIndicator extends HookConsumerWidget { + final String agentPubkey; + final String agentLabel; + final String? channelId; + final String? threadRootId; + final Widget child; + + /// Diameter of [child] (e.g. an avatar). The ring is drawn just outside it. + final double childDiameter; + + const AgentUsageIndicator({ + super.key, + required this.agentPubkey, + required this.agentLabel, + this.channelId, + this.threadRootId, + required this.child, + this.childDiameter = 40, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + useEffect(() { + unawaited( + ref + .read(agentUsageRelayProvider.notifier) + .ensureAgentHistory( + agentPubkey: agentPubkey, + channelId: channelId, + threadRootId: threadRootId, + ), + ); + return null; + }, [agentPubkey, channelId, threadRootId]); + final relayState = ref.watch(agentUsageRelayProvider); + final snapshot = relayState.snapshotFor( + agentPubkey: agentPubkey, + channelId: channelId, + threadRootId: threadRootId, + ); + final subscriptionErrored = + relayState.connection == AgentUsageConnectionState.error; + final status = agentUsageStatusFor( + snapshot, + subscriptionErrored: subscriptionErrored, + ); + final fraction = primaryUsageFraction(snapshot); + + final ringDiameter = childDiameter + Grid.half * 2; + final color = _colorFor(context, status, fraction); + + return Semantics( + button: true, + label: _semanticsLabel(status, fraction), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showAgentUsageDetailSheet( + context: context, + agentLabel: agentLabel, + snapshot: snapshot, + status: status, + ), + child: ExcludeSemantics( + child: SizedBox( + width: ringDiameter, + height: ringDiameter, + child: CustomPaint( + painter: + status == AgentUsageStatus.unknown || + status == AgentUsageStatus.unavailable + ? null + : _UsageRingPainter( + fraction: fraction, + color: color, + dashed: status == AgentUsageStatus.stale, + isError: status == AgentUsageStatus.error, + ), + child: Center( + child: SizedBox( + width: childDiameter, + height: childDiameter, + child: child, + ), + ), + ), + ), + ), + ), + ); + } + + static Color _colorFor( + BuildContext context, + AgentUsageStatus status, + double? fraction, + ) { + if (status == AgentUsageStatus.error) return context.colors.error; + if (fraction == null) return context.colors.onSurfaceVariant; + if (fraction >= 0.9) return context.colors.error; + if (fraction >= 0.7) return context.appColors.warning; + return context.colors.onSurfaceVariant; + } + + static String _semanticsLabel(AgentUsageStatus status, double? fraction) { + switch (status) { + case AgentUsageStatus.unknown: + return 'Agent usage: no data yet'; + case AgentUsageStatus.unavailable: + return 'Agent usage: unavailable'; + case AgentUsageStatus.error: + return 'Agent usage: unavailable'; + case AgentUsageStatus.stale: + final pct = fraction != null ? '${(fraction * 100).round()}%, ' : ''; + return 'Agent usage: ${pct}data may be out of date'; + case AgentUsageStatus.fresh: + final pct = fraction != null + ? '${(fraction * 100).round()}% used' + : 'active'; + return 'Agent usage: $pct'; + } + } +} + +/// Small visible percentage placed next to an agent name. The ring remains the +/// tap target; this label deliberately renders nothing for unknown usage rather +/// than inventing a zero. +class AgentUsagePercentText extends ConsumerWidget { + final String agentPubkey; + final String? channelId; + final String? threadRootId; + + const AgentUsagePercentText({ + super.key, + required this.agentPubkey, + this.channelId, + this.threadRootId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final relayState = ref.watch(agentUsageRelayProvider); + final snapshot = relayState.snapshotFor( + agentPubkey: agentPubkey, + channelId: channelId, + threadRootId: threadRootId, + ); + final fraction = primaryUsageFraction(snapshot); + if (fraction == null) return const SizedBox.shrink(); + + final status = agentUsageStatusFor( + snapshot, + subscriptionErrored: + relayState.connection == AgentUsageConnectionState.error, + ); + final percent = (fraction * 100).round(); + return Text( + '$percent%', + style: context.textTheme.labelSmall?.copyWith( + color: AgentUsageIndicator._colorFor(context, status, fraction), + fontFeatures: const [FontFeature.tabularFigures()], + fontWeight: FontWeight.w600, + ), + ); + } +} + +/// Paints a thin progress arc around the child avatar. A solid arc for fresh +/// data, a dashed arc when the underlying snapshot is [AgentUsageStatus.stale], +/// and a full dim ring with no progress readout when [isError] (no data and +/// the subscription itself is unhealthy). +class _UsageRingPainter extends CustomPainter { + final double? fraction; + final Color color; + final bool dashed; + final bool isError; + + static const _strokeWidth = 2.5; + + const _UsageRingPainter({ + required this.fraction, + required this.color, + required this.dashed, + required this.isError, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (math.min(size.width, size.height) - _strokeWidth) / 2; + final rect = Rect.fromCircle(center: center, radius: radius); + final backgroundPaint = Paint() + ..color = color.withValues(alpha: 0.2) + ..style = PaintingStyle.stroke + ..strokeWidth = _strokeWidth; + canvas.drawArc(rect, 0, 2 * math.pi, false, backgroundPaint); + + if (isError) { + return; + } + + final sweep = 2 * math.pi * (fraction ?? 1.0); + final foregroundPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _strokeWidth + ..strokeCap = StrokeCap.round; + + if (!dashed) { + canvas.drawArc(rect, -math.pi / 2, sweep, false, foregroundPaint); + return; + } + + const dashLength = 0.12; // radians of arc drawn per dash + const gapLength = 0.08; // radians of arc skipped per dash + var drawn = 0.0; + var start = -math.pi / 2; + while (drawn < sweep) { + final segment = math.min(dashLength, sweep - drawn); + canvas.drawArc(rect, start, segment, false, foregroundPaint); + start += dashLength + gapLength; + drawn += dashLength + gapLength; + } + } + + @override + bool shouldRepaint(covariant _UsageRingPainter oldDelegate) { + return oldDelegate.fraction != fraction || + oldDelegate.color != color || + oldDelegate.dashed != dashed || + oldDelegate.isError != isError; + } +} diff --git a/mobile/lib/shared/agent_usage/agent_usage_models.dart b/mobile/lib/shared/agent_usage/agent_usage_models.dart new file mode 100644 index 00000000000..17823b4f621 --- /dev/null +++ b/mobile/lib/shared/agent_usage/agent_usage_models.dart @@ -0,0 +1,524 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +/// How stale [AgentUsageSnapshot.lastEventAt] must be before the compact +/// indicator stops presenting cached data as current. +const staleAfter = Duration(minutes: 45); + +int _utf8Length(String value) => utf8.encode(value).length; + +int? _optionalTokenCount(Map json, String field) { + if (!json.containsKey(field)) return null; + final value = json[field]; + if (value == null) return null; + if (value is! int || value < 0) { + throw FormatException('$field must be a non-negative integer'); + } + return value; +} + +double? _optionalCost(Map json, String field) { + if (!json.containsKey(field)) return null; + final value = json[field]; + if (value == null) return null; + if (value is! num) { + throw FormatException('$field must be a non-negative finite number'); + } + final parsed = value.toDouble(); + if (!parsed.isFinite || parsed < 0) { + throw FormatException('$field must be a non-negative finite number'); + } + return parsed; +} + +DateTime _parseRfc3339(Object? value, String field) { + if (value is! String || !RegExp(r'(?:Z|[+-]\d{2}:\d{2})$').hasMatch(value)) { + throw FormatException('$field must be RFC 3339'); + } + final parsed = DateTime.tryParse(value); + if (parsed == null) throw FormatException('$field must be RFC 3339'); + return parsed; +} + +/// Token-usage counters for one measurement window (a turn or a session +/// cumulative), decoded from a NIP-AM `kind:44200` payload. Null fields mean +/// the harness did not report them — NOT that the count was zero. +@immutable +class AgentTokenCounts { + final int? inputTokens; + final int? outputTokens; + final int? totalTokens; + final double? costUsd; + final int? cacheReadTokens; + final int? cacheWriteTokens; + + const AgentTokenCounts({ + this.inputTokens, + this.outputTokens, + this.totalTokens, + this.costUsd, + this.cacheReadTokens, + this.cacheWriteTokens, + }); + + /// Throws [FormatException] for a non-finite or negative `costUsd`, + /// mirroring NIP-AM §Numeric validity (`buzz-core`'s + /// `AgentTurnMetricPayload::validate`). + factory AgentTokenCounts.fromJson(Map json) { + return AgentTokenCounts( + inputTokens: _optionalTokenCount(json, 'inputTokens'), + outputTokens: _optionalTokenCount(json, 'outputTokens'), + totalTokens: _optionalTokenCount(json, 'totalTokens'), + costUsd: _optionalCost(json, 'costUsd'), + cacheReadTokens: _optionalTokenCount(json, 'cacheReadTokens'), + cacheWriteTokens: _optionalTokenCount(json, 'cacheWriteTokens'), + ); + } + + bool get hasObservation => + inputTokens != null || + outputTokens != null || + totalTokens != null || + costUsd != null || + cacheReadTokens != null || + cacheWriteTokens != null; +} + +/// A provider-account quota window (`accountUsageWindows` entry), e.g. +/// `{"label": "Session", "usedPercent": 12.5, "resetAt": "..."}`. +@immutable +class AgentUsageWindow { + final String label; + final double usedPercent; + final DateTime? resetAt; + + const AgentUsageWindow({ + required this.label, + required this.usedPercent, + this.resetAt, + }); + + /// Throws [FormatException] on a missing label or a non-finite/negative + /// `usedPercent`, matching the NIP-AM validity contract. + factory AgentUsageWindow.fromJson(Map json) { + final label = json['label'] as String?; + if (label == null || label.trim().isEmpty || _utf8Length(label) > 128) { + throw const FormatException('accountUsageWindows entry missing label'); + } + final usedPercentRaw = json['usedPercent']; + final usedPercent = usedPercentRaw is num + ? usedPercentRaw.toDouble() + : double.nan; + if (!usedPercent.isFinite || usedPercent < 0) { + throw const FormatException( + 'accountUsageWindows.usedPercent must be finite and non-negative', + ); + } + final resetAt = json.containsKey('resetAt') + ? _parseRfc3339(json['resetAt'], 'accountUsageWindows.resetAt') + : null; + return AgentUsageWindow( + label: label, + usedPercent: usedPercent, + resetAt: resetAt, + ); + } +} + +/// Decrypted payload of a `kind:44200` NIP-AM Agent Turn Metric event. +/// Models only the fields the compact usage display consumes; unrecognized +/// JSON fields are ignored per the NIP's forward-compatibility contract. +@immutable +class AgentTurnMetricPayload { + final String harness; + final String? model; + final String? channelId; + final String? threadRootId; + final DateTime timestamp; + final AgentTokenCounts? cumulative; + final int? contextUsedTokens; + final int? contextLimitTokens; + final List accountUsageWindows; + + const AgentTurnMetricPayload({ + required this.harness, + this.model, + this.channelId, + this.threadRootId, + required this.timestamp, + this.cumulative, + this.contextUsedTokens, + this.contextLimitTokens, + this.accountUsageWindows = const [], + }); + + /// Throws [FormatException] when required fields are missing or any + /// numeric field fails NIP-AM validity — callers MUST drop the whole event + /// on failure rather than partially ingest it (NIP-AM Client Behavior: + /// "ignore events that fail to decrypt or parse"). + factory AgentTurnMetricPayload.fromJson(Map json) { + final harness = json['harness'] as String?; + if (harness == null || + harness.trim().isEmpty || + _utf8Length(harness) > 128) { + throw const FormatException('agent turn metric missing harness'); + } + final timestamp = _parseRfc3339(json['timestamp'], 'timestamp'); + + final model = json['model'] as String?; + final channelId = json['channelId'] as String?; + final threadRootId = json['threadRootId'] as String?; + for (final field in [ + ('model', model), + ('channelId', channelId), + ('sessionId', json['sessionId'] as String?), + ('turnId', json['turnId'] as String?), + ]) { + final value = field.$2; + if (value != null && (value.trim().isEmpty || _utf8Length(value) > 256)) { + throw FormatException( + '${field.$1} must be non-empty and at most 256 bytes', + ); + } + } + if (threadRootId != null && + (channelId == null || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(threadRootId))) { + throw const FormatException( + 'threadRootId requires channelId and 64 lowercase hex characters', + ); + } + + final contextUsedTokens = _optionalTokenCount(json, 'contextUsedTokens'); + final contextLimitTokens = _optionalTokenCount(json, 'contextLimitTokens'); + if ((contextUsedTokens == null) != (contextLimitTokens == null) || + (contextUsedTokens != null && + (contextUsedTokens == 0 || contextLimitTokens == 0))) { + throw const FormatException( + 'contextUsedTokens and contextLimitTokens must be a complete positive pair', + ); + } + + final windowsRaw = json['accountUsageWindows']; + if (windowsRaw != null && windowsRaw is! List) { + throw const FormatException('accountUsageWindows must be an array'); + } + final windows = []; + for (final entry in windowsRaw as List? ?? const []) { + if (entry is! Map) { + throw const FormatException( + 'accountUsageWindows entries must be objects', + ); + } + windows.add(AgentUsageWindow.fromJson(entry)); + } + if (windows.length > 64) { + throw const FormatException( + 'accountUsageWindows must contain at most 64 entries', + ); + } + + final turnRaw = json['turn']; + if (turnRaw != null && turnRaw is! Map) { + throw const FormatException('turn must be an object'); + } + final turn = turnRaw is Map + ? AgentTokenCounts.fromJson(turnRaw) + : null; + + final cumulativeRaw = json['cumulative']; + if (cumulativeRaw != null && cumulativeRaw is! Map) { + throw const FormatException('cumulative must be an object'); + } + final cumulative = cumulativeRaw is Map + ? AgentTokenCounts.fromJson(cumulativeRaw) + : null; + if (cumulative != null) { + final sessionId = json['sessionId']; + final turnSeq = json['turnSeq']; + if (sessionId is! String || + sessionId.trim().isEmpty || + turnSeq is! int || + turnSeq < 0) { + throw const FormatException( + 'sessionId and turnSeq are required with cumulative', + ); + } + } + + final pricingRaw = json['pricingIdentity']; + if (pricingRaw != null) { + if (pricingRaw is! Map) { + throw const FormatException('pricingIdentity must be an object'); + } + final authority = pricingRaw['authority']; + final pricingModel = pricingRaw['model']; + const authorities = { + 'api.anthropic.com', + 'api.openai.com', + 'openrouter.ai', + }; + if (authority is! String || + !authorities.contains(authority) || + pricingModel is! String || + pricingModel.trim().isEmpty || + _utf8Length(pricingModel) > 256) { + throw const FormatException('invalid pricingIdentity'); + } + final cacheClass = pricingRaw['cacheClass']; + if (cacheClass != null && + (cacheClass is! String || + cacheClass.trim().isEmpty || + _utf8Length(cacheClass) > 256)) { + throw const FormatException('invalid pricingIdentity.cacheClass'); + } + } + + if (turn?.hasObservation != true && + cumulative?.hasObservation != true && + contextUsedTokens == null && + windows.isEmpty) { + throw const FormatException('agent turn metric contains no observation'); + } + + return AgentTurnMetricPayload( + harness: harness, + model: model, + channelId: channelId, + threadRootId: threadRootId, + timestamp: timestamp, + cumulative: cumulative, + contextUsedTokens: contextUsedTokens, + contextLimitTokens: contextLimitTokens, + accountUsageWindows: List.unmodifiable(windows), + ); + } +} + +/// Merged latest-known usage state for one agent, built by folding a stream +/// of [AgentTurnMetricPayload]s field-by-field (see [mergeAgentUsageSnapshot]). +/// Each field tracks its own "as of" timestamp because a producer may report +/// some fields on one turn and omit them on the next — the display always +/// wants the newest *known* value per field, not just the newest event. +@immutable +class AgentUsageSnapshot { + final String harness; + final String? model; + final DateTime lastEventAt; + final String lastEventId; + + final int? contextUsedTokens; + final int? contextLimitTokens; + final DateTime? contextSnapshotAt; + final String? contextSnapshotEventId; + + final List accountUsageWindows; + final DateTime? accountUsageWindowsAt; + final String? accountUsageWindowsEventId; + + final AgentTokenCounts? cumulative; + final DateTime? cumulativeAt; + final String? cumulativeEventId; + + const AgentUsageSnapshot({ + required this.harness, + this.model, + required this.lastEventAt, + this.lastEventId = '', + this.contextUsedTokens, + this.contextLimitTokens, + this.contextSnapshotAt, + this.contextSnapshotEventId, + this.accountUsageWindows = const [], + this.accountUsageWindowsAt, + this.accountUsageWindowsEventId, + this.cumulative, + this.cumulativeAt, + this.cumulativeEventId, + }); + + /// Fraction (0.0–1.0) of context window consumed, clamped for display. + /// `contextUsedTokens` MAY exceed `contextLimitTokens` on the wire (NIP-AM); + /// callers render the clamped bar but keep the raw values for the detail + /// view. + double? get contextUsedFraction { + final used = contextUsedTokens; + final limit = contextLimitTokens; + if (used == null || limit == null || limit <= 0) return null; + return (used / limit).clamp(0.0, 1.0); + } +} + +/// Folds [payload] (decrypted from an event with payload-declared time +/// [payload.timestamp]) into [previous], keeping each field's newest known +/// value independently. +/// +/// A field is only overwritten when [payload] actually reports it (NIP-AM: +/// omission means "not reported", never "cleared") AND [payload.timestamp] is +/// not older than that field's current "as of" time — out-of-order delivery +/// (relay replay, backfill racing the live subscription) must not let a +/// stale event clobber newer data. +AgentUsageSnapshot mergeAgentUsageSnapshot( + AgentUsageSnapshot? previous, + AgentTurnMetricPayload payload, { + String eventId = '', +}) { + final useAsLatest = _isNewerObservation( + payload.timestamp, + eventId, + previous?.lastEventAt, + previous?.lastEventId, + ); + final lastEventAt = useAsLatest ? payload.timestamp : previous!.lastEventAt; + final lastEventId = useAsLatest ? eventId : previous!.lastEventId; + + final useContext = + payload.contextUsedTokens != null && + payload.contextLimitTokens != null && + _isNewerObservation( + payload.timestamp, + eventId, + previous?.contextSnapshotAt, + previous?.contextSnapshotEventId, + ); + + final useWindows = + payload.accountUsageWindows.isNotEmpty && + _isNewerObservation( + payload.timestamp, + eventId, + previous?.accountUsageWindowsAt, + previous?.accountUsageWindowsEventId, + ); + + final useCumulative = + payload.cumulative != null && + _isNewerObservation( + payload.timestamp, + eventId, + previous?.cumulativeAt, + previous?.cumulativeEventId, + ); + + // harness/model are descriptive of the publishing agent process; take them + // from whichever event is currently newest overall. + final useDescriptive = useAsLatest; + + return AgentUsageSnapshot( + harness: useDescriptive ? payload.harness : previous!.harness, + model: useDescriptive + ? (payload.model ?? previous?.model) + : previous!.model, + lastEventAt: lastEventAt, + lastEventId: lastEventId, + contextUsedTokens: useContext + ? payload.contextUsedTokens + : previous?.contextUsedTokens, + contextLimitTokens: useContext + ? payload.contextLimitTokens + : previous?.contextLimitTokens, + contextSnapshotAt: useContext + ? payload.timestamp + : previous?.contextSnapshotAt, + contextSnapshotEventId: useContext + ? eventId + : previous?.contextSnapshotEventId, + accountUsageWindows: useWindows + ? payload.accountUsageWindows + : (previous?.accountUsageWindows ?? const []), + accountUsageWindowsAt: useWindows + ? payload.timestamp + : previous?.accountUsageWindowsAt, + accountUsageWindowsEventId: useWindows + ? eventId + : previous?.accountUsageWindowsEventId, + cumulative: useCumulative ? payload.cumulative : previous?.cumulative, + cumulativeAt: useCumulative ? payload.timestamp : previous?.cumulativeAt, + cumulativeEventId: useCumulative ? eventId : previous?.cumulativeEventId, + ); +} + +bool _isNewerObservation( + DateTime candidateAt, + String candidateEventId, + DateTime? currentAt, + String? currentEventId, +) { + if (currentAt == null) return true; + final timeOrder = candidateAt.compareTo(currentAt); + if (timeOrder != 0) return timeOrder > 0; + return candidateEventId.compareTo(currentEventId ?? '') > 0; +} + +/// Display status for the compact usage indicator. +enum AgentUsageStatus { + /// No usage event has ever been observed for this agent yet. + unknown, + + /// An event exists, but it contains no context or account-quota observation. + unavailable, + + /// A usage snapshot exists and is recent. + fresh, + + /// A usage snapshot exists but is older than [staleAfter]. + stale, + + /// No usage snapshot exists and the live subscription is not healthy, so + /// none is expected soon. + error, +} + +({double fraction, DateTime timestamp})? _primaryUsageMetric( + AgentUsageSnapshot snapshot, +) { + final known = <({double fraction, DateTime timestamp})>[ + ...snapshot.accountUsageWindows.map( + (window) => ( + fraction: (window.usedPercent / 100).clamp(0.0, 1.0).toDouble(), + timestamp: snapshot.accountUsageWindowsAt ?? snapshot.lastEventAt, + ), + ), + if (snapshot.contextUsedFraction != null) + ( + fraction: snapshot.contextUsedFraction!, + timestamp: snapshot.contextSnapshotAt ?? snapshot.lastEventAt, + ), + ]; + if (known.isEmpty) return null; + return known.reduce( + (highest, candidate) => + candidate.fraction > highest.fraction ? candidate : highest, + ); +} + +AgentUsageStatus agentUsageStatusFor( + AgentUsageSnapshot? snapshot, { + required bool subscriptionErrored, + DateTime? now, +}) { + if (snapshot == null) { + return subscriptionErrored + ? AgentUsageStatus.error + : AgentUsageStatus.unknown; + } + if (_primaryUsageMetric(snapshot) == null) { + return AgentUsageStatus.unavailable; + } + final effectiveNow = now ?? DateTime.now(); + final statusTimestamp = + _primaryUsageMetric(snapshot)?.timestamp ?? snapshot.lastEventAt; + if (effectiveNow.difference(statusTimestamp) > staleAfter) { + return AgentUsageStatus.stale; + } + return AgentUsageStatus.fresh; +} + +/// The single most urgent usage fraction (0.0–1.0) to surface on the compact +/// ring: the highest known provider-account or context-window usage, or null +/// when the publisher did not report either. +double? primaryUsageFraction(AgentUsageSnapshot? snapshot) { + if (snapshot == null) return null; + return _primaryUsageMetric(snapshot)?.fraction; +} diff --git a/mobile/lib/shared/agent_usage/agent_usage_subscription.dart b/mobile/lib/shared/agent_usage/agent_usage_subscription.dart new file mode 100644 index 00000000000..640c43f152b --- /dev/null +++ b/mobile/lib/shared/agent_usage/agent_usage_subscription.dart @@ -0,0 +1,635 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip44.dart'; +import '../relay/relay.dart'; +import 'agent_usage_models.dart'; + +/// Bound on tracked recent event ids so a long-lived session subscription +/// cannot grow this set without limit (mirrors the observer subscription's +/// `_maxObserverEvents` cap). +const _maxRecentEventIds = 500; +const _maxTrackedAgents = 64; +const _maxTrackedScopes = 256; +const _historyPageSize = 200; +const _maxHistoryPages = 25; + +typedef AgentUsageScopeKey = ({ + String agentPubkey, + String channelId, + String? threadRootId, +}); +typedef AgentUsageHistoryKey = ({ + String agentPubkey, + String? channelId, + String? threadRootId, +}); + +bool _hasValidNip01Signature(NostrEvent event) { + try { + nostr.Event.fromMap(event.toJson(), verify: true); + return true; + } on Object { + return false; + } +} + +/// Owner-scoped live state for all agents' usage snapshots. +@immutable +class AgentUsageRelayState { + final AgentUsageConnectionState connection; + final Map snapshotsByAgent; + final Map scopedSnapshots; + final String? errorMessage; + + const AgentUsageRelayState({ + required this.connection, + required this.snapshotsByAgent, + this.scopedSnapshots = const {}, + this.errorMessage, + }); + + const AgentUsageRelayState.initial() + : connection = AgentUsageConnectionState.idle, + snapshotsByAgent = const {}, + scopedSnapshots = const {}, + errorMessage = null; + + /// Returns agent-wide provider windows combined with context from the exact + /// requested channel/thread. With no channel, returns the agent-wide view. + AgentUsageSnapshot? snapshotFor({ + required String agentPubkey, + String? channelId, + String? threadRootId, + }) { + final normalizedAgent = agentPubkey.toLowerCase(); + final global = snapshotsByAgent[normalizedAgent]; + if (channelId == null) return global; + + final scoped = + scopedSnapshots[( + agentPubkey: normalizedAgent, + channelId: channelId, + threadRootId: threadRootId, + )]; + if (global == null && scoped == null) return null; + final descriptive = global ?? scoped!; + final lastEventAt = global == null + ? scoped!.lastEventAt + : scoped == null || global.lastEventAt.isAfter(scoped.lastEventAt) + ? global.lastEventAt + : scoped.lastEventAt; + return AgentUsageSnapshot( + harness: descriptive.harness, + model: descriptive.model, + lastEventAt: lastEventAt, + contextUsedTokens: scoped?.contextUsedTokens, + contextLimitTokens: scoped?.contextLimitTokens, + contextSnapshotAt: scoped?.contextSnapshotAt, + accountUsageWindows: global?.accountUsageWindows ?? const [], + accountUsageWindowsAt: global?.accountUsageWindowsAt, + cumulative: scoped?.cumulative, + cumulativeAt: scoped?.cumulativeAt, + ); + } +} + +/// Connection state for the usage relay subscription. Reuses the same shape +/// as the NIP-AO observer subscription's connection states. +enum AgentUsageConnectionState { idle, connecting, open, error } + +/// Subscribes to `{kinds:[44200], #p:[ownerPubkey]}` (NIP-AM Client +/// Behavior), decrypts each event with NIP-44, and folds decrypted payloads +/// into a per-agent [AgentUsageSnapshot] via [mergeAgentUsageSnapshot]. +/// +/// Per-event decrypt/parse failures are dropped silently and do NOT surface +/// as a connection error — NIP-AM's Client Behavior section requires clients +/// to "ignore events that fail to decrypt or parse", since one malformed or +/// foreign-keyed event must not degrade the usage view for every other +/// agent. Only subscription-level failures (bad local key, relay CLOSED) +/// set [AgentUsageRelayState.errorMessage]. +class AgentUsageRelayNotifier extends Notifier { + final Map _snapshotsByAgent = {}; + final Map _scopedSnapshots = {}; + final List _agentLru = []; + final List _scopeLru = []; + final Set _trackedAgents = {}; + final Set _historyRequests = {}; + final Set _historyInFlight = {}; + final Set _historyCompleted = {}; + final List _recentEventIds = []; + final Set _recentEventIdSet = {}; + final Map _conversationKeysByAgent = {}; + + void Function()? _unsubscribe; + Future? _startFuture; + String? _privHex; + String? _ownerPubkey; + String? _identityKey; + String? _errorMessage; + int _subscriptionEpoch = 0; + bool _disposed = false; + + @override + AgentUsageRelayState build() { + final config = ref.watch(relayConfigProvider); + final sessionState = ref.watch(relaySessionProvider); + final nsec = config.nsec; + String publicIdentity = ''; + if (nsec?.isNotEmpty == true) { + try { + publicIdentity = _derivePubkey(_decodePrivkey(nsec!)); + } on Object { + publicIdentity = 'invalid'; + } + } + final identityKey = '${config.baseUrl}|$publicIdentity'; + + _disposed = false; + if (_identityKey != null && _identityKey != identityKey) { + _reset(); + } + _identityKey = identityKey; + + ref.onDispose(() { + _disposed = true; + _reset(); + }); + + if (sessionState.status == SessionStatus.connected) { + Future.microtask(_ensureSubscribed); + } + + final hasSigningKey = config.nsec?.isNotEmpty == true; + return AgentUsageRelayState( + connection: hasSigningKey + ? _connectionForSession(sessionState.status) + : AgentUsageConnectionState.idle, + snapshotsByAgent: _snapshotFrames(), + scopedSnapshots: _scopedSnapshotFrames(), + errorMessage: _errorMessage, + ); + } + + /// Registers an agent already identified by trusted local channel/profile + /// state. Unknown relay authors are never decrypted or retained. + void trackAgent(String agentPubkey) { + final normalizedAgent = agentPubkey.toLowerCase(); + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(normalizedAgent)) return; + if (!_trackedAgents.contains(normalizedAgent) && + _trackedAgents.length >= _maxTrackedAgents) { + _evictAgent(_trackedAgents.first); + } + _trackedAgents + ..remove(normalizedAgent) + ..add(normalizedAgent); + } + + /// Registers a known agent and starts a bounded owner-scoped backfill. + Future ensureAgentHistory({ + required String agentPubkey, + String? channelId, + String? threadRootId, + }) { + final normalizedAgent = agentPubkey.toLowerCase(); + trackAgent(normalizedAgent); + if (!_trackedAgents.contains(normalizedAgent)) return Future.value(); + final request = ( + agentPubkey: normalizedAgent, + channelId: channelId, + threadRootId: threadRootId, + ); + if (!_rememberHistoryRequest(request)) return Future.value(); + return _startHistoryBackfill(request); + } + + bool _rememberHistoryRequest(AgentUsageHistoryKey request) { + if (_historyRequests.remove(request)) { + _historyRequests.add(request); + return true; + } + if (_historyRequests.length >= _maxTrackedScopes) { + AgentUsageHistoryKey? evicted; + for (final candidate in _historyRequests) { + if (!_historyInFlight.contains(candidate)) { + evicted = candidate; + break; + } + } + if (evicted == null) return false; + _historyRequests.remove(evicted); + _historyCompleted.remove(evicted); + } + _historyRequests.add(request); + return true; + } + + Future _startHistoryBackfill(AgentUsageHistoryKey request) async { + final ownerPubkey = _ownerPubkey; + final epoch = _subscriptionEpoch; + if (ownerPubkey == null || + _historyCompleted.contains(request) || + !_historyInFlight.add(request)) { + return; + } + + try { + final session = ref.read(relaySessionProvider.notifier); + int? until; + var completed = false; + final seenEventIds = {}; + for (var page = 0; page < _maxHistoryPages; page++) { + final events = await session.fetchHistory( + NostrFilter( + kinds: [EventKind.agentTurnMetric], + authors: [request.agentPubkey], + tags: { + '#p': [ownerPubkey], + }, + limit: _historyPageSize, + until: until, + ), + ); + if (_disposed || epoch != _subscriptionEpoch) return; + var newEventCount = 0; + for (final event in events) { + if (seenEventIds.add(event.id)) { + newEventCount += 1; + _handleEvent(event, emit: false); + } + } + if (events.isNotEmpty) { + _emit(connection: AgentUsageConnectionState.open); + } + if (_hasRequiredHistory(request)) { + completed = true; + break; + } + if (events.length < _historyPageSize) { + completed = true; + break; + } + final oldest = events + .map((event) => event.createdAt) + .reduce((a, b) => a < b ? a : b); + final nextUntil = oldest; + if (nextUntil < 0 || (until != null && nextUntil > until)) break; + if (until == nextUntil && newEventCount == 0) break; + until = nextUntil; + } + if (!_disposed && + epoch == _subscriptionEpoch && + completed && + _historyRequests.contains(request)) { + _historyCompleted.add(request); + } + } catch (error) { + debugPrint('[AgentUsage] historical reconstruction failed: $error'); + } finally { + if (epoch == _subscriptionEpoch) { + _historyInFlight.remove(request); + } + } + } + + bool _hasRequiredHistory(AgentUsageHistoryKey request) { + final global = _snapshotsByAgent[request.agentPubkey]; + final scoped = request.channelId == null + ? global + : _scopedSnapshots[( + agentPubkey: request.agentPubkey, + channelId: request.channelId!, + threadRootId: request.threadRootId, + )]; + final hasContext = + scoped?.contextUsedTokens != null && scoped?.contextLimitTokens != null; + final hasWindows = global?.accountUsageWindows.isNotEmpty == true; + return hasContext && hasWindows; + } + + Future _ensureSubscribed() { + if (_disposed) return Future.value(); + if (_unsubscribe != null) return Future.value(); + if (_startFuture != null) return _startFuture!; + + _startFuture = _subscribe(_subscriptionEpoch); + return _startFuture!; + } + + Future _subscribe(int epoch) async { + try { + if (_disposed || epoch != _subscriptionEpoch) { + return; + } + + final config = ref.read(relayConfigProvider); + final nsec = config.nsec; + if (nsec == null || nsec.isEmpty) { + _errorMessage = null; + _emit(connection: AgentUsageConnectionState.idle); + return; + } + + final privHex = _decodePrivkey(nsec); + final ownerPubkey = _derivePubkey(privHex); + if (_disposed || epoch != _subscriptionEpoch) { + return; + } + _privHex = privHex; + _ownerPubkey = ownerPubkey; + _errorMessage = null; + _emit(connection: AgentUsageConnectionState.connecting); + + final session = ref.read(relaySessionProvider.notifier); + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: [EventKind.agentTurnMetric], + tags: { + '#p': [ownerPubkey], + }, + limit: 200, + ), + _handleEvent, + onClosed: (message) { + _unsubscribe = null; + _errorMessage = 'Agent usage subscription closed: $message'; + _emit(connection: AgentUsageConnectionState.error); + }, + ); + + if (_disposed || epoch != _subscriptionEpoch) { + unsubscribe(); + return; + } + + _unsubscribe = unsubscribe; + _emit(connection: AgentUsageConnectionState.open); + for (final request in _historyRequests.toList(growable: false)) { + unawaited(_startHistoryBackfill(request)); + } + } catch (error) { + if (_disposed || epoch != _subscriptionEpoch) { + return; + } + _errorMessage = _usageErrorMessage(error); + _emit(connection: AgentUsageConnectionState.error); + } finally { + if (epoch == _subscriptionEpoch) { + _startFuture = null; + } + } + } + + void _handleEvent(NostrEvent event, {bool emit = true}) { + if (event.kind != EventKind.agentTurnMetric || + event.content.length < nip44MinContentLength || + event.content.length > nip44MaxContentLength || + event.tags.any((tag) => tag.isNotEmpty && tag.first == 'h')) { + return; + } + + // Relays are untrusted. Verify the canonical event id and Schnorr + // signature before deduplication so a forged duplicate cannot poison the + // recent-id cache and suppress a later valid event. + if (!_hasValidNip01Signature(event)) { + return; + } + + // NIP-AM: events MUST carry exactly one `p` tag (owner) and one `agent` + // tag equal to `pubkey`. Tag matching alone is not an authorization + // decision here (the relay already gates reads to the owner), but a + // malformed envelope cannot be decrypted meaningfully either way. + final agentTags = event.tags + .where((tag) => tag.isNotEmpty && tag.first == 'agent') + .toList(growable: false); + final ownerTags = event.tags + .where((tag) => tag.isNotEmpty && tag.first == 'p') + .toList(growable: false); + if (agentTags.length != 1 || + agentTags.single.length != 2 || + ownerTags.length != 1 || + ownerTags.single.length != 2) { + return; + } + final agentPubkey = agentTags.single[1]; + final pTag = ownerTags.single[1]; + final canonicalPubkey = RegExp(r'^[0-9a-f]{64}$'); + if (!canonicalPubkey.hasMatch(event.pubkey) || + !canonicalPubkey.hasMatch(agentPubkey) || + !canonicalPubkey.hasMatch(pTag) || + event.pubkey != agentPubkey) { + return; + } + + final ownerPubkey = _ownerPubkey; + final privHex = _privHex; + if (ownerPubkey == null || privHex == null) { + return; + } + + if (pTag != ownerPubkey.toLowerCase()) { + return; + } + + final normalizedAgent = agentPubkey.toLowerCase(); + if (!_trackedAgents.contains(normalizedAgent)) return; + if (!_recentEventIdSet.add(event.id)) { + return; + } + _recentEventIds.add(event.id); + if (_recentEventIds.length > _maxRecentEventIds) { + final removed = _recentEventIds.removeAt(0); + _recentEventIdSet.remove(removed); + } + final payload = _decryptPayload(event, normalizedAgent, privHex); + if (payload == null) return; + _touchAgent(normalizedAgent); + + final merged = mergeAgentUsageSnapshot( + _snapshotsByAgent[normalizedAgent], + payload, + eventId: event.id, + ); + _snapshotsByAgent[normalizedAgent] = merged; + final channelId = payload.channelId; + if (channelId != null) { + final scopeKey = ( + agentPubkey: normalizedAgent, + channelId: channelId, + threadRootId: payload.threadRootId, + ); + _touchScope(scopeKey); + _scopedSnapshots[scopeKey] = mergeAgentUsageSnapshot( + _scopedSnapshots[scopeKey], + payload, + eventId: event.id, + ); + } + if (emit) _emit(connection: AgentUsageConnectionState.open); + } + + /// Decrypts and parses one event's payload. Returns null and drops the + /// event silently on any failure, per NIP-AM Client Behavior — this MUST + /// NOT be surfaced as a subscription-level error (see class doc). + AgentTurnMetricPayload? _decryptPayload( + NostrEvent event, + String normalizedAgent, + String privHex, + ) { + try { + final conversationKey = + _conversationKeysByAgent[normalizedAgent] ?? + getConversationKey(privHex, normalizedAgent); + final plaintext = nip44Decrypt(conversationKey, event.content); + final json = jsonDecode(plaintext) as Map; + final payload = AgentTurnMetricPayload.fromJson(json); + _conversationKeysByAgent[normalizedAgent] = conversationKey; + return payload; + } catch (error) { + debugPrint('[AgentUsage] dropping unparseable kind:44200 event: $error'); + return null; + } + } + + void _emit({required AgentUsageConnectionState connection}) { + if (_disposed) return; + state = AgentUsageRelayState( + connection: connection, + snapshotsByAgent: _snapshotFrames(), + scopedSnapshots: _scopedSnapshotFrames(), + errorMessage: _errorMessage, + ); + } + + Map _snapshotFrames() { + return Map.unmodifiable(_snapshotsByAgent); + } + + Map _scopedSnapshotFrames() { + return Map.unmodifiable( + _scopedSnapshots, + ); + } + + void _touchAgent(String agentPubkey) { + _agentLru.remove(agentPubkey); + if (!_snapshotsByAgent.containsKey(agentPubkey) && + _snapshotsByAgent.length >= _maxTrackedAgents) { + _evictAgent(_agentLru.first); + } + _agentLru.add(agentPubkey); + } + + void _evictAgent(String agentPubkey) { + _trackedAgents.remove(agentPubkey); + _agentLru.remove(agentPubkey); + _snapshotsByAgent.remove(agentPubkey); + _conversationKeysByAgent.remove(agentPubkey); + _historyRequests.removeWhere( + (request) => request.agentPubkey == agentPubkey, + ); + _historyCompleted.removeWhere( + (request) => request.agentPubkey == agentPubkey, + ); + _historyInFlight.removeWhere( + (request) => request.agentPubkey == agentPubkey, + ); + final evictedScopes = _scopeLru + .where((scope) => scope.agentPubkey == agentPubkey) + .toList(growable: false); + for (final scope in evictedScopes) { + _scopeLru.remove(scope); + _scopedSnapshots.remove(scope); + } + } + + void _touchScope(AgentUsageScopeKey scope) { + _scopeLru.remove(scope); + if (!_scopedSnapshots.containsKey(scope) && + _scopedSnapshots.length >= _maxTrackedScopes) { + _scopedSnapshots.remove(_scopeLru.removeAt(0)); + } + _scopeLru.add(scope); + } + + void _reset() { + _subscriptionEpoch += 1; + _unsubscribe?.call(); + _unsubscribe = null; + _startFuture = null; + _privHex = null; + _ownerPubkey = null; + _errorMessage = null; + _snapshotsByAgent.clear(); + _scopedSnapshots.clear(); + _agentLru.clear(); + _scopeLru.clear(); + _trackedAgents.clear(); + _historyRequests.clear(); + _historyInFlight.clear(); + _historyCompleted.clear(); + _recentEventIds.clear(); + _recentEventIdSet.clear(); + _conversationKeysByAgent.clear(); + } + + static String _decodePrivkey(String nsec) { + try { + final privHex = nostr.Nip19.decode(payload: nsec).data; + if (privHex.isEmpty) { + throw const FormatException('empty private key'); + } + return privHex; + } catch (_) { + throw const FormatException('failed to decode private key'); + } + } + + static String _derivePubkey(String privHex) { + try { + return nostr.Keys(privHex).public; + } catch (_) { + throw const FormatException('failed to derive pubkey'); + } + } + + AgentUsageConnectionState _connectionForSession(SessionStatus status) { + if (_errorMessage != null && _unsubscribe == null && _startFuture == null) { + return AgentUsageConnectionState.error; + } + return switch (status) { + SessionStatus.connected => + _unsubscribe == null + ? AgentUsageConnectionState.connecting + : AgentUsageConnectionState.open, + SessionStatus.connecting || + SessionStatus.reconnecting => AgentUsageConnectionState.connecting, + SessionStatus.disconnected => AgentUsageConnectionState.idle, + }; + } + + static String _usageErrorMessage(Object error) { + if (error is FormatException) { + return error.message; + } + return 'Agent usage subscription failed: $error'; + } +} + +final agentUsageRelayProvider = + NotifierProvider( + AgentUsageRelayNotifier.new, + ); + +/// Per-agent view over [agentUsageRelayProvider], for widgets that only need +/// one agent's snapshot. +final agentUsageSnapshotProvider = Provider.family( + (ref, agentPubkey) { + final relayState = ref.watch(agentUsageRelayProvider); + return relayState.snapshotsByAgent[agentPubkey.toLowerCase()]; + }, +); diff --git a/mobile/lib/shared/crypto/nip44.dart b/mobile/lib/shared/crypto/nip44.dart index 32e025be18e..c115f32fe53 100644 --- a/mobile/lib/shared/crypto/nip44.dart +++ b/mobile/lib/shared/crypto/nip44.dart @@ -9,6 +9,11 @@ import 'package:pointycastle/stream/chacha7539.dart'; import 'ecdh.dart'; import 'hkdf.dart'; +/// NIP-44 v2 limits shared with `buzz-core/src/observer.rs`. +const nip44MinContentLength = 132; +const nip44MaxContentLength = 87472; +const nip44MaxPlaintextLength = 65535; + /// NIP-44 v2 conversation key derivation. /// /// conversation_key = HKDF-Extract(salt="nip44-v2", ikm=ecdh_shared_secret) @@ -57,6 +62,12 @@ String nip44Encrypt(Uint8List conversationKey, String plaintext) { /// /// Takes base64-encoded payload, returns plaintext string. String nip44Decrypt(Uint8List conversationKey, String payloadBase64) { + if (payloadBase64.length < nip44MinContentLength || + payloadBase64.length > nip44MaxContentLength) { + throw FormatException( + 'NIP-44 ciphertext length out of range: ${payloadBase64.length}', + ); + } final payload = base64.decode(payloadBase64); // Minimum: version(1) + nonce(32) + min_ciphertext(32) + mac(32) = 97 @@ -120,7 +131,7 @@ Uint8List _pad(Uint8List plaintext) { String _unpad(Uint8List padded) { if (padded.length < 2) throw FormatException('Padded data too short'); final len = (padded[0] << 8) | padded[1]; - if (len == 0 || 2 + len > padded.length) { + if (len == 0 || len > nip44MaxPlaintextLength || 2 + len > padded.length) { throw FormatException('Invalid padding length: $len'); } return utf8.decode(padded.sublist(2, 2 + len)); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index c041e4399fa..c24d47b17de 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -24,6 +24,9 @@ abstract final class EventKind { static const typingIndicator = 20002; static const auth = 22242; static const agentObserverFrame = 24200; + + /// NIP-AM: kind:44200 agent turn metric, NIP-44 encrypted to the owner. + static const agentTurnMetric = 44200; static const huddleReaction = 24810; static const readState = 30078; static const eventReminder = 30300; diff --git a/mobile/test/shared/agent_usage/agent_usage_indicator_test.dart b/mobile/test/shared/agent_usage/agent_usage_indicator_test.dart new file mode 100644 index 00000000000..3d2ef79d206 --- /dev/null +++ b/mobile/test/shared/agent_usage/agent_usage_indicator_test.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/agent_usage/agent_usage_indicator.dart'; +import 'package:buzz/shared/agent_usage/agent_usage_models.dart'; +import 'package:buzz/shared/agent_usage/agent_usage_subscription.dart'; + +import '../../helpers/widget_helpers.dart'; + +const _agentPubkey = 'deadbeef'; + +void main() { + Widget buildIndicator(AgentUsageRelayState fixedState) { + return WidgetHelpers.testable( + overrides: [ + agentUsageRelayProvider.overrideWith( + () => _FixedAgentUsageRelayNotifier(fixedState), + ), + ], + child: const AgentUsageIndicator( + agentPubkey: _agentPubkey, + agentLabel: 'Test Bot', + child: CircleAvatar(radius: 20, child: Text('T')), + ), + ); + } + + testWidgets('shows an "unknown" state with no ring when no data exists', ( + tester, + ) async { + await tester.pumpWidget( + buildIndicator( + const AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: {}, + ), + ), + ); + + expect(find.bySemanticsLabel('Agent usage: no data yet'), findsOneWidget); + }); + + testWidgets( + 'shows an "error" state when the subscription is unhealthy and no data exists', + (tester) async { + await tester.pumpWidget( + buildIndicator( + const AgentUsageRelayState( + connection: AgentUsageConnectionState.error, + snapshotsByAgent: {}, + errorMessage: 'boom', + ), + ), + ); + + expect(find.bySemanticsLabel('Agent usage: unavailable'), findsOneWidget); + }, + ); + + testWidgets('shows unavailable without drawing a false progress ring', ( + tester, + ) async { + await tester.pumpWidget( + buildIndicator( + AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: { + _agentPubkey: AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.now(), + ), + }, + ), + ), + ); + + final semantics = find.bySemanticsLabel('Agent usage: unavailable'); + expect(semantics, findsOneWidget); + final paints = find.descendant( + of: semantics, + matching: find.byType(CustomPaint), + ); + expect(paints, findsOneWidget); + expect(tester.widget(paints).painter, isNull); + }); + + testWidgets('shows the usage percentage when a fresh snapshot exists', ( + tester, + ) async { + await tester.pumpWidget( + buildIndicator( + AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: { + _agentPubkey: AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.now(), + contextUsedTokens: 50, + contextLimitTokens: 100, + ), + }, + ), + ), + ); + + expect(find.bySemanticsLabel('Agent usage: 50% used'), findsOneWidget); + }); + + testWidgets('shows a visible usage percentage beside an agent name', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + agentUsageRelayProvider.overrideWith( + () => _FixedAgentUsageRelayNotifier( + AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: { + _agentPubkey: AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.now(), + contextUsedTokens: 50, + contextLimitTokens: 100, + ), + }, + ), + ), + ), + ], + child: const AgentUsagePercentText(agentPubkey: _agentPubkey), + ), + ); + + expect(find.text('50%'), findsOneWidget); + }); + + testWidgets('does not invent a percentage when usage is unknown', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + agentUsageRelayProvider.overrideWith( + () => _FixedAgentUsageRelayNotifier( + const AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: {}, + ), + ), + ), + ], + child: const AgentUsagePercentText(agentPubkey: _agentPubkey), + ), + ); + + expect(find.text('0%'), findsNothing); + }); + + testWidgets('flags stale data once it exceeds the freshness window', ( + tester, + ) async { + await tester.pumpWidget( + buildIndicator( + AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: { + _agentPubkey: AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.now().subtract(const Duration(days: 2)), + contextUsedTokens: 50, + contextLimitTokens: 100, + ), + }, + ), + ), + ); + + expect( + find.bySemanticsLabel('Agent usage: 50%, data may be out of date'), + findsOneWidget, + ); + }); + + testWidgets('keeps many provider windows reachable by scrolling', ( + tester, + ) async { + final windows = List.generate( + 30, + (index) => AgentUsageWindow( + label: 'Provider window $index with a deliberately long label', + usedPercent: index.toDouble(), + ), + ); + await tester.pumpWidget( + buildIndicator( + AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: { + _agentPubkey: AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.now(), + accountUsageWindows: windows, + ), + }, + ), + ), + ); + + await tester.tap(find.bySemanticsLabel('Agent usage: 29% used')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Provider window 29 with a deliberately long label'), + 300, + scrollable: find.byType(Scrollable).last, + ); + expect( + find.text('Provider window 29 with a deliberately long label'), + findsOneWidget, + ); + }); + + testWidgets('tapping opens the detail sheet with the agent label as title', ( + tester, + ) async { + await tester.pumpWidget( + buildIndicator( + const AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: {}, + ), + ), + ); + + await tester.tap(find.bySemanticsLabel('Agent usage: no data yet')); + await tester.pumpAndSettle(); + + expect(find.text('Test Bot'), findsOneWidget); + expect(find.text('No usage data reported yet.'), findsOneWidget); + }); +} + +class _FixedAgentUsageRelayNotifier extends AgentUsageRelayNotifier { + final AgentUsageRelayState fixedState; + + _FixedAgentUsageRelayNotifier(this.fixedState); + + @override + AgentUsageRelayState build() => fixedState; +} diff --git a/mobile/test/shared/agent_usage/agent_usage_models_test.dart b/mobile/test/shared/agent_usage/agent_usage_models_test.dart new file mode 100644 index 00000000000..bbe5fccdc16 --- /dev/null +++ b/mobile/test/shared/agent_usage/agent_usage_models_test.dart @@ -0,0 +1,547 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/agent_usage/agent_usage_models.dart'; + +void main() { + group('AgentTurnMetricPayload.fromJson', () { + test('rejects a payload with no observation', () { + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03.213Z', + }), + throwsFormatException, + ); + }); + + test('throws when harness is missing', () { + expect( + () => AgentTurnMetricPayload.fromJson({ + 'timestamp': '2026-07-01T20:11:03Z', + }), + throwsFormatException, + ); + }); + + test('throws when timestamp is missing or unparseable', () { + expect( + () => AgentTurnMetricPayload.fromJson({'harness': 'goose'}), + throwsFormatException, + ); + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': 'not-a-date', + }), + throwsFormatException, + ); + }); + + test('ignores unknown fields (forward compatibility)', () { + final payload = AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'turn': {'inputTokens': 1}, + 'somethingFromTheFuture': {'nested': true}, + }); + expect(payload.harness, 'goose'); + }); + + test('counts string limits in UTF-8 bytes', () { + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'accountUsageWindows': [ + {'label': List.filled(65, 'é').join(), 'usedPercent': 10}, + ], + }), + throwsFormatException, + ); + }); + + test('parses context window snapshot and account usage windows', () { + final payload = AgentTurnMetricPayload.fromJson({ + 'harness': 'buzz-agent', + 'timestamp': '2026-07-01T20:11:03Z', + 'contextUsedTokens': 15392, + 'contextLimitTokens': 272000, + 'accountUsageWindows': [ + { + 'label': 'Session', + 'usedPercent': 12.5, + 'resetAt': '2026-07-02T01:00:00Z', + }, + {'label': 'Weekly', 'usedPercent': 41.0}, + ], + }); + expect(payload.contextUsedTokens, 15392); + expect(payload.contextLimitTokens, 272000); + expect(payload.accountUsageWindows, hasLength(2)); + expect(payload.accountUsageWindows.first.label, 'Session'); + expect(payload.accountUsageWindows.first.resetAt, isNotNull); + expect(payload.accountUsageWindows.last.resetAt, isNull); + }); + + test('rejects negative or fractional token counts', () { + for (final value in [-1, 1.5]) { + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'contextUsedTokens': value, + }), + throwsFormatException, + ); + } + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'cumulative': {'inputTokens': -1}, + }), + throwsFormatException, + ); + }); + + test('rejects malformed usage-window shapes and reset times', () { + for (final windows in [ + 'not-an-array', + ['not-an-object'], + [ + {'label': 'Session', 'usedPercent': 12, 'resetAt': 'not-rfc3339'}, + ], + ]) { + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'accountUsageWindows': windows, + }), + throwsFormatException, + ); + } + }); + + test( + 'rejects a non-finite or negative accountUsageWindows.usedPercent', + () { + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'accountUsageWindows': [ + {'label': 'Session', 'usedPercent': -5.0}, + ], + }), + throwsFormatException, + ); + }, + ); + + test('rejects a negative cumulative.costUsd', () { + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'cumulative': {'costUsd': -0.5}, + }), + throwsFormatException, + ); + }); + + test('parses cumulative token counts', () { + final payload = AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'sessionId': 'session-1', + 'turnSeq': 1, + 'cumulative': { + 'inputTokens': 45210, + 'outputTokens': 9876, + 'totalTokens': 55086, + 'costUsd': 0.41, + }, + }); + expect(payload.cumulative?.totalTokens, 55086); + expect(payload.cumulative?.costUsd, 0.41); + }); + + test('accepts nullable legacy counts without inventing values', () { + final payload = AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'sessionId': 'session-1', + 'turnSeq': 1, + 'cumulative': {'inputTokens': null, 'totalTokens': 0, 'costUsd': null}, + 'contextUsedTokens': null, + 'contextLimitTokens': null, + }); + + expect(payload.cumulative?.inputTokens, isNull); + expect(payload.cumulative?.totalTokens, 0); + expect(payload.cumulative?.costUsd, isNull); + expect(payload.contextUsedTokens, isNull); + expect(payload.contextLimitTokens, isNull); + }); + + test('rejects incomplete or zero context pairs', () { + for (final context in [ + {'contextUsedTokens': 1}, + {'contextUsedTokens': 0, 'contextLimitTokens': 100}, + {'contextUsedTokens': 1, 'contextLimitTokens': 0}, + ]) { + expect( + () => AgentTurnMetricPayload.fromJson({ + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + ...context, + }), + throwsFormatException, + ); + } + }); + }); + + group('mergeAgentUsageSnapshot', () { + AgentTurnMetricPayload payloadAt( + String timestamp, { + int? contextUsedTokens, + int? contextLimitTokens, + List accountUsageWindows = const [], + AgentTokenCounts? cumulative, + String harness = 'goose', + String? model, + }) { + return AgentTurnMetricPayload( + harness: harness, + model: model, + timestamp: DateTime.parse(timestamp), + contextUsedTokens: contextUsedTokens, + contextLimitTokens: contextLimitTokens, + accountUsageWindows: accountUsageWindows, + cumulative: cumulative, + ); + } + + test('first event seeds every field', () { + final snapshot = mergeAgentUsageSnapshot( + null, + payloadAt( + '2026-07-01T10:00:00Z', + contextUsedTokens: 1000, + contextLimitTokens: 2000, + accountUsageWindows: const [ + AgentUsageWindow(label: 'Session', usedPercent: 10), + ], + ), + ); + expect(snapshot.contextUsedTokens, 1000); + expect(snapshot.accountUsageWindows.single.label, 'Session'); + expect(snapshot.lastEventAt, DateTime.parse('2026-07-01T10:00:00Z')); + }); + + test( + 'a later event that omits a field keeps the previous value for that field', + () { + final first = mergeAgentUsageSnapshot( + null, + payloadAt( + '2026-07-01T10:00:00Z', + accountUsageWindows: const [ + AgentUsageWindow(label: 'Session', usedPercent: 10), + ], + ), + ); + + // Second turn reports a fresh context snapshot but no usage windows + // this time — the windows must not be wiped out. + final second = mergeAgentUsageSnapshot( + first, + payloadAt( + '2026-07-01T10:05:00Z', + contextUsedTokens: 500, + contextLimitTokens: 1000, + ), + ); + + expect(second.contextUsedTokens, 500); + expect(second.accountUsageWindows.single.label, 'Session'); + expect( + second.accountUsageWindowsAt, + DateTime.parse('2026-07-01T10:00:00Z'), + ); + expect(second.lastEventAt, DateTime.parse('2026-07-01T10:05:00Z')); + }, + ); + + test('an out-of-order (older) event does not clobber newer field data', () { + final first = mergeAgentUsageSnapshot( + null, + payloadAt( + '2026-07-01T10:05:00Z', + contextUsedTokens: 500, + contextLimitTokens: 1000, + ), + ); + + // A stale/backfilled event for an earlier turn arrives after the fact. + final second = mergeAgentUsageSnapshot( + first, + payloadAt( + '2026-07-01T10:00:00Z', + contextUsedTokens: 999999, + contextLimitTokens: 1000000, + ), + ); + + expect(second.contextUsedTokens, 500); + expect(second.contextLimitTokens, 1000); + // lastEventAt tracks the newest event seen overall, regardless of order. + expect(second.lastEventAt, DateTime.parse('2026-07-01T10:05:00Z')); + }); + + test('accountUsageWindows replaces the whole list, not per-label', () { + final first = mergeAgentUsageSnapshot( + null, + payloadAt( + '2026-07-01T10:00:00Z', + accountUsageWindows: const [ + AgentUsageWindow(label: 'Session', usedPercent: 10), + AgentUsageWindow(label: 'Weekly', usedPercent: 20), + ], + ), + ); + + final second = mergeAgentUsageSnapshot( + first, + payloadAt( + '2026-07-01T10:05:00Z', + accountUsageWindows: const [ + AgentUsageWindow(label: 'Session', usedPercent: 15), + ], + ), + ); + + // The newer snapshot is authoritative wholesale — "Weekly" is gone, not + // merged forward, matching NIP-AM's "last-write-wins display snapshot". + expect(second.accountUsageWindows, hasLength(1)); + expect(second.accountUsageWindows.single.usedPercent, 15); + }); + + test('model persists once reported even if a later event omits it', () { + final first = mergeAgentUsageSnapshot( + null, + payloadAt('2026-07-01T10:00:00Z', model: 'model-alpha'), + ); + final second = mergeAgentUsageSnapshot( + first, + payloadAt('2026-07-01T10:05:00Z'), + ); + expect(second.model, 'model-alpha'); + }); + + test('equal timestamps use the event id as a deterministic tiebreaker', () { + final timestamp = payloadAt( + '2026-07-01T10:00:00Z', + contextUsedTokens: 20, + contextLimitTokens: 100, + ); + final lower = payloadAt( + '2026-07-01T10:00:00Z', + contextUsedTokens: 10, + contextLimitTokens: 100, + ); + final higherId = List.filled(64, 'b').join(); + final lowerId = List.filled(64, 'a').join(); + final highThenLow = mergeAgentUsageSnapshot( + mergeAgentUsageSnapshot(null, timestamp, eventId: higherId), + lower, + eventId: lowerId, + ); + final lowThenHigh = mergeAgentUsageSnapshot( + mergeAgentUsageSnapshot(null, lower, eventId: lowerId), + timestamp, + eventId: higherId, + ); + + expect(highThenLow.contextUsedTokens, 20); + expect(lowThenHigh.contextUsedTokens, 20); + expect(highThenLow.contextSnapshotEventId, higherId); + expect(lowThenHigh.contextSnapshotEventId, higherId); + }); + }); + + group('agentUsageStatusFor', () { + test('unknown when there is no snapshot and no subscription error', () { + expect( + agentUsageStatusFor(null, subscriptionErrored: false), + AgentUsageStatus.unknown, + ); + }); + + test( + 'error when there is no snapshot and the subscription is unhealthy', + () { + expect( + agentUsageStatusFor(null, subscriptionErrored: true), + AgentUsageStatus.error, + ); + }, + ); + + test('unavailable when a snapshot has no context or account quota', () { + final snapshot = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.parse('2026-07-01T10:00:00Z'), + ); + expect( + agentUsageStatusFor( + snapshot, + subscriptionErrored: true, + now: DateTime.parse('2026-07-01T10:05:00Z'), + ), + AgentUsageStatus.unavailable, + ); + }); + + test('fresh when usable quota data is recent', () { + final snapshot = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.parse('2026-07-01T10:00:00Z'), + contextUsedTokens: 10, + contextLimitTokens: 100, + ); + expect( + agentUsageStatusFor( + snapshot, + subscriptionErrored: true, + now: DateTime.parse('2026-07-01T10:05:00Z'), + ), + AgentUsageStatus.fresh, + ); + }); + + test('stale once the snapshot exceeds the freshness window', () { + final snapshot = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.parse('2026-07-01T00:00:00Z'), + contextUsedTokens: 10, + contextLimitTokens: 100, + ); + expect( + agentUsageStatusFor( + snapshot, + subscriptionErrored: false, + now: DateTime.parse('2026-07-01T10:00:00Z'), + ), + AgentUsageStatus.stale, + ); + }); + + test('uses the shared 45 minute freshness window', () { + final snapshot = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.parse('2026-07-01T10:00:00Z'), + contextUsedTokens: 10, + contextLimitTokens: 100, + ); + expect( + agentUsageStatusFor( + snapshot, + subscriptionErrored: false, + now: DateTime.parse('2026-07-01T10:45:00Z'), + ), + AgentUsageStatus.fresh, + ); + expect( + agentUsageStatusFor( + snapshot, + subscriptionErrored: false, + now: DateTime.parse('2026-07-01T10:45:01Z'), + ), + AgentUsageStatus.stale, + ); + }); + test('uses the dominant metric field timestamp for freshness', () { + final now = DateTime.parse('2026-07-01T11:00:00Z'); + final staleContext = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: now.subtract(const Duration(minutes: 1)), + contextUsedTokens: 90, + contextLimitTokens: 100, + contextSnapshotAt: now.subtract(const Duration(minutes: 46)), + accountUsageWindows: const [ + AgentUsageWindow(label: 'Session', usedPercent: 20), + ], + accountUsageWindowsAt: now.subtract(const Duration(minutes: 1)), + ); + final freshAllowance = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: now.subtract(const Duration(minutes: 1)), + contextUsedTokens: 10, + contextLimitTokens: 100, + contextSnapshotAt: now.subtract(const Duration(minutes: 46)), + accountUsageWindows: const [ + AgentUsageWindow(label: 'Session', usedPercent: 80), + ], + accountUsageWindowsAt: now.subtract(const Duration(minutes: 1)), + ); + + expect( + agentUsageStatusFor(staleContext, subscriptionErrored: false, now: now), + AgentUsageStatus.stale, + ); + expect( + agentUsageStatusFor( + freshAllowance, + subscriptionErrored: false, + now: now, + ), + AgentUsageStatus.fresh, + ); + }); + }); + + group('primaryUsageFraction', () { + test('null when there is no snapshot', () { + expect(primaryUsageFraction(null), isNull); + }); + + test( + 'falls back to context window fraction when there are no quota windows', + () { + final snapshot = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.now(), + contextUsedTokens: 50, + contextLimitTokens: 100, + ); + expect(primaryUsageFraction(snapshot), 0.5); + }, + ); + + test('prefers the highest account usage window over context usage', () { + final snapshot = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.now(), + contextUsedTokens: 10, + contextLimitTokens: 100, + accountUsageWindows: const [ + AgentUsageWindow(label: 'Session', usedPercent: 30), + AgentUsageWindow(label: 'Weekly', usedPercent: 87), + ], + ); + expect(primaryUsageFraction(snapshot), closeTo(0.87, 0.0001)); + }); + + test('clamps a context fraction that exceeds the reported limit', () { + final snapshot = AgentUsageSnapshot( + harness: 'goose', + lastEventAt: DateTime.now(), + contextUsedTokens: 150, + contextLimitTokens: 100, + ); + expect(primaryUsageFraction(snapshot), 1.0); + }); + }); +} diff --git a/mobile/test/shared/agent_usage/agent_usage_subscription_test.dart b/mobile/test/shared/agent_usage/agent_usage_subscription_test.dart new file mode 100644 index 00000000000..b28d8c956f0 --- /dev/null +++ b/mobile/test/shared/agent_usage/agent_usage_subscription_test.dart @@ -0,0 +1,867 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/agent_usage/agent_usage_subscription.dart'; +import 'package:buzz/shared/agent_usage/agent_usage_models.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/relay/relay.dart'; + +void main() { + test('provider initializes without circular dependency error', () { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => _RecordingRelaySession()), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: null), + ), + ], + ); + addTearDown(container.dispose); + + final state = container.read(agentUsageRelayProvider); + expect(state.connection, AgentUsageConnectionState.idle); + expect(state.snapshotsByAgent, isEmpty); + }); + + test('transitions to error when nsec is invalid', () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => _RecordingRelaySession()), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: 'nsec1invalid'), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + + final state = container.read(agentUsageRelayProvider); + expect(state.connection, AgentUsageConnectionState.error); + expect(state.errorMessage, isNotNull); + }); + + test('subscribes with the NIP-AM owner-scoped filter shape', () async { + final userKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: userKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + + final state = container.read(agentUsageRelayProvider); + expect(state.connection, AgentUsageConnectionState.open); + + expect(relaySession.filters, hasLength(1)); + final filter = relaySession.filters.first; + expect(filter.kinds, [EventKind.agentTurnMetric]); + expect(filter.tags['#p'], [userKeychain.public]); + }); + + test('identity switch clears trusted agents and history requests', () async { + final firstOwner = nostr.Keys.generate(); + final secondOwner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final relayConfig = _FakeRelayConfigNotifier(nsec: firstOwner.nsec); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith(() => relayConfig), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen( + agentUsageRelayProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + + await Future.delayed(Duration.zero); + await container + .read(agentUsageRelayProvider.notifier) + .ensureAgentHistory(agentPubkey: agent.public); + expect(relaySession.historyFilters, hasLength(1)); + + relayConfig.setIdentity(secondOwner.nsec); + for (var attempt = 0; attempt < 20; attempt++) { + await Future.delayed(Duration.zero); + if (relaySession.filters.length == 1 && + relaySession.filters.single.tags['#p']?.single == + secondOwner.public) { + break; + } + } + expect(relaySession.filters, hasLength(1)); + expect(relaySession.filters.single.tags['#p'], [secondOwner.public]); + + expect( + relaySession.historyFilters, + hasLength(1), + reason: 'the previous identity history request must not be replayed', + ); + relaySession.emit( + _turnMetricEvent( + ownerKeychain: secondOwner, + agentKeychain: agent, + payload: { + 'harness': 'test-harness', + 'timestamp': '2026-07-01T20:11:03Z', + }, + ), + ); + expect( + container.read(agentUsageRelayProvider).snapshotsByAgent, + isEmpty, + reason: 'the previous identity agent must be trusted again explicitly', + ); + }); + + test('bounds remembered history scopes and evicts the oldest', () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(historyPages: const [[]]); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + for (var index = 0; index <= 256; index++) { + await container + .read(agentUsageRelayProvider.notifier) + .ensureAgentHistory( + agentPubkey: agentKeychain.public, + channelId: index.toRadixString(16).padLeft(64, '0'), + ); + } + expect(relaySession.historyFilters, hasLength(257)); + + await container + .read(agentUsageRelayProvider.notifier) + .ensureAgentHistory( + agentPubkey: agentKeychain.public, + channelId: 0.toRadixString(16).padLeft(64, '0'), + ); + + expect(relaySession.historyFilters, hasLength(258)); + }); + + test( + 'replays a pre-registration event after the agent becomes trusted', + () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final event = _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'v': 1, + 'harness': 'goose', + 'timestamp': '2026-07-01T12:00:00Z', + 'contextUsedTokens': 100, + 'contextLimitTokens': 200, + }, + ); + final relaySession = _RecordingRelaySession( + historyPages: [ + [event], + ], + ); + final relayConfig = _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith(() => relayConfig), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen( + agentUsageRelayProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await Future.delayed(Duration.zero); + + relaySession.emit(event); + await Future.delayed(Duration.zero); + expect(container.read(agentUsageRelayProvider).snapshotsByAgent, isEmpty); + + await container + .read(agentUsageRelayProvider.notifier) + .ensureAgentHistory(agentPubkey: agentKeychain.public); + + expect( + container + .read(agentUsageRelayProvider) + .snapshotsByAgent[agentKeychain.public] + ?.contextUsedTokens, + 100, + ); + }, + ); + + test('decrypts a kind:44200 event into a per-agent snapshot', () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + container + .read(agentUsageRelayProvider.notifier) + .trackAgent(agentKeychain.public); + + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'contextUsedTokens': 15392, + 'contextLimitTokens': 272000, + }, + ), + ); + + final state = container.read(agentUsageRelayProvider); + expect(state.connection, AgentUsageConnectionState.open); + final snapshot = state.snapshotsByAgent[agentKeychain.public]; + expect(snapshot, isNotNull); + expect(snapshot!.contextUsedTokens, 15392); + expect(snapshot.contextLimitTokens, 272000); + }); + + test('drops an event with an invalid NIP-01 signature', () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + container + .read(agentUsageRelayProvider.notifier) + .trackAgent(agentKeychain.public); + + final validEvent = _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'contextUsedTokens': 15392, + 'contextLimitTokens': 272000, + }, + ); + relaySession.emit( + NostrEvent.fromJson({...validEvent.toJson(), 'sig': '00' * 64}), + ); + + final state = container.read(agentUsageRelayProvider); + expect(state.connection, AgentUsageConnectionState.open); + expect(state.snapshotsByAgent, isEmpty); + expect(state.errorMessage, isNull); + }); + + test('drops an event with the wrong p tag without erroring', () async { + final ownerKeychain = nostr.Keys.generate(); + final otherOwnerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + container + .read(agentUsageRelayProvider.notifier) + .trackAgent(agentKeychain.public); + + relaySession.emit( + _turnMetricEvent( + ownerKeychain: otherOwnerKeychain, + agentKeychain: agentKeychain, + payload: {'harness': 'goose', 'timestamp': '2026-07-01T20:11:03Z'}, + ), + ); + + final state = container.read(agentUsageRelayProvider); + expect(state.connection, AgentUsageConnectionState.open); + expect(state.snapshotsByAgent, isEmpty); + expect(state.errorMessage, isNull); + }); + + test('drops malformed NIP-AM envelopes before snapshot ingestion', () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + container + .read(agentUsageRelayProvider.notifier) + .trackAgent(agentKeychain.public); + const payload = {'harness': 'goose', 'timestamp': '2026-07-01T20:11:03Z'}; + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: payload, + kind: EventKind.note, + ), + ); + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: payload, + extraTags: [ + ['p', ownerKeychain.public], + ], + ), + ); + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: payload, + extraTags: [ + ['agent', agentKeychain.public], + ], + ), + ); + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: payload, + extraTags: const [ + ['h', 'private-channel'], + ], + ), + ); + + expect(container.read(agentUsageRelayProvider).snapshotsByAgent, isEmpty); + }); + + test('bounds tracked agents and ignores an evicted relay author', () async { + final ownerKeychain = nostr.Keys.generate(); + final oldestAgent = nostr.Keys.generate(); + final newestAgent = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + final notifier = container.read(agentUsageRelayProvider.notifier); + notifier.trackAgent(oldestAgent.public); + for (var index = 1; index < 64; index++) { + notifier.trackAgent(index.toRadixString(16).padLeft(64, '0')); + } + notifier.trackAgent(newestAgent.public); + + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: oldestAgent, + payload: const { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'contextUsedTokens': 10, + 'contextLimitTokens': 100, + }, + ), + ); + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: newestAgent, + payload: const { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:12:03Z', + 'contextUsedTokens': 20, + 'contextLimitTokens': 100, + }, + ), + ); + + final snapshots = container.read(agentUsageRelayProvider).snapshotsByAgent; + expect(snapshots, hasLength(1)); + expect(snapshots, isNot(contains(oldestAgent.public))); + expect(snapshots[newestAgent.public]?.contextUsedTokens, 20); + }); + + test( + 'a malformed/unparseable event is dropped silently, not surfaced as an error', + () async { + // NIP-AM Client Behavior: "ignore events that fail to decrypt or + // parse" — a single bad event from one agent must not degrade the + // usage view for every other agent. + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + container + .read(agentUsageRelayProvider.notifier) + .trackAgent(agentKeychain.public); + + // Missing "harness" — fails AgentTurnMetricPayload.fromJson. + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: {'timestamp': '2026-07-01T20:11:03Z'}, + ), + ); + + final state = container.read(agentUsageRelayProvider); + expect(state.connection, AgentUsageConnectionState.open); + expect(state.errorMessage, isNull); + expect(state.snapshotsByAgent, isEmpty); + }, + ); + + test( + 'a later event does not erase a field the newest event omitted', + () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + container + .read(agentUsageRelayProvider.notifier) + .trackAgent(agentKeychain.public); + + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:00:00Z', + 'accountUsageWindows': [ + {'label': 'Session', 'usedPercent': 10.0}, + ], + }, + ), + ); + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:05:00Z', + 'contextUsedTokens': 200, + 'contextLimitTokens': 1000, + }, + ), + ); + + final snapshot = container + .read(agentUsageRelayProvider) + .snapshotsByAgent[agentKeychain.public]; + expect(snapshot!.contextUsedTokens, 200); + expect(snapshot.accountUsageWindows.single.label, 'Session'); + }, + ); + + test( + 'agentUsageSnapshotProvider exposes one agent from the shared relay state', + () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + container + .read(agentUsageRelayProvider.notifier) + .trackAgent(agentKeychain.public); + + expect( + container.read(agentUsageSnapshotProvider(agentKeychain.public)), + isNull, + ); + + relaySession.emit( + _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:11:03Z', + 'contextUsedTokens': 10, + 'contextLimitTokens': 100, + }, + ), + ); + + final snapshot = container.read( + agentUsageSnapshotProvider(agentKeychain.public.toUpperCase()), + ); + expect(snapshot?.contextUsedTokens, 10); + }, + ); + test( + 'keeps the oldest second inclusive while draining a full history page', + () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + const createdAt = 1782936300; + final quotaEvent = _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:05:00Z', + 'accountUsageWindows': [ + {'label': 'Session', 'usedPercent': 25}, + ], + }, + createdAt: createdAt, + ); + final contextEvent = _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'channelId': 'channel-a', + 'timestamp': '2026-07-01T20:00:00Z', + 'contextUsedTokens': 100, + 'contextLimitTokens': 1000, + }, + createdAt: createdAt, + ); + final fillerEvents = List.generate( + 199, + (index) => _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:04:00Z', + 'sessionId': 'session-a', + 'turnSeq': index, + 'cumulative': {'inputTokens': index + 1}, + }, + createdAt: createdAt, + ), + ); + final relaySession = _RecordingRelaySession( + historyPages: [ + [quotaEvent, ...fillerEvents], + [contextEvent], + ], + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + await container + .read(agentUsageRelayProvider.notifier) + .ensureAgentHistory( + agentPubkey: agentKeychain.public, + channelId: 'channel-a', + ); + + expect(relaySession.historyFilters, hasLength(2)); + expect(relaySession.historyFilters.first.authors, [agentKeychain.public]); + expect(relaySession.historyFilters.first.tags['#p'], [ + ownerKeychain.public, + ]); + expect(relaySession.historyFilters.last.until, createdAt); + final snapshot = container + .read(agentUsageRelayProvider) + .snapshotFor( + agentPubkey: agentKeychain.public, + channelId: 'channel-a', + ); + expect(snapshot?.contextUsedTokens, 100); + expect(snapshot?.accountUsageWindows.single.usedPercent, 25); + }, + ); + + test( + 'does not complete history when a full boundary page makes no progress', + () async { + final ownerKeychain = nostr.Keys.generate(); + final agentKeychain = nostr.Keys.generate(); + final repeatedEvent = _turnMetricEvent( + ownerKeychain: ownerKeychain, + agentKeychain: agentKeychain, + payload: { + 'harness': 'goose', + 'timestamp': '2026-07-01T20:05:00Z', + 'accountUsageWindows': [ + {'label': 'Session', 'usedPercent': 25}, + ], + }, + createdAt: 1782936300, + ); + final fullPage = List.filled(200, repeatedEvent); + final relaySession = _RecordingRelaySession( + historyPages: [fullPage, fullPage], + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec), + ), + ], + ); + addTearDown(container.dispose); + + container.read(agentUsageRelayProvider); + await Future.delayed(Duration.zero); + final notifier = container.read(agentUsageRelayProvider.notifier); + await notifier.ensureAgentHistory( + agentPubkey: agentKeychain.public, + channelId: 'channel-a', + ); + expect(relaySession.historyFilters, hasLength(2)); + + await notifier.ensureAgentHistory( + agentPubkey: agentKeychain.public, + channelId: 'channel-a', + ); + expect(relaySession.historyFilters, hasLength(3)); + }, + ); + + test('combines global provider windows with exact scoped context', () { + final global = AgentUsageSnapshot( + harness: 'test-harness', + lastEventAt: DateTime.utc(2026, 7, 1, 3), + contextUsedTokens: 900, + contextLimitTokens: 1000, + contextSnapshotAt: DateTime.utc(2026, 7, 1, 3), + accountUsageWindows: const [ + AgentUsageWindow(label: 'Weekly', usedPercent: 80), + ], + accountUsageWindowsAt: DateTime.utc(2026, 7, 1, 3), + ); + final scoped = AgentUsageSnapshot( + harness: 'test-harness', + lastEventAt: DateTime.utc(2026, 7, 1, 1), + contextUsedTokens: 100, + contextLimitTokens: 1000, + contextSnapshotAt: DateTime.utc(2026, 7, 1, 1), + ); + final state = AgentUsageRelayState( + connection: AgentUsageConnectionState.open, + snapshotsByAgent: {'agent-a': global}, + scopedSnapshots: { + (agentPubkey: 'agent-a', channelId: 'channel-a', threadRootId: null): + scoped, + }, + ); + + final channelA = state.snapshotFor( + agentPubkey: 'AGENT-A', + channelId: 'channel-a', + ); + final unknownChannel = state.snapshotFor( + agentPubkey: 'agent-a', + channelId: 'channel-b', + ); + + expect(channelA?.contextUsedTokens, 100); + expect(channelA?.accountUsageWindows.single.usedPercent, 80); + expect(unknownChannel?.contextUsedTokens, isNull); + expect(unknownChannel?.accountUsageWindows.single.usedPercent, 80); + }); +} + +NostrEvent _turnMetricEvent({ + required nostr.Keys ownerKeychain, + required nostr.Keys agentKeychain, + required Map payload, + int kind = EventKind.agentTurnMetric, + List> extraTags = const [], + int? createdAt, +}) { + final conversationKey = getConversationKey( + agentKeychain.secret, + ownerKeychain.public, + ); + final event = nostr.Event.from( + kind: kind, + content: nip44Encrypt(conversationKey, jsonEncode(payload)), + createdAt: createdAt, + tags: [ + ['p', ownerKeychain.public], + ['agent', agentKeychain.public], + ...extraTags, + ], + secretKey: agentKeychain.secret, + verify: false, + ); + return NostrEvent.fromJson(event.toMap()); +} + +class _RecordingRelaySession extends RelaySessionNotifier { + final List filters = []; + final List historyFilters = []; + final List> historyPages; + final List _listeners = []; + final List _closedListeners = []; + + _RecordingRelaySession({List> historyPages = const []}) + : historyPages = List.of(historyPages); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + historyFilters.add(filter); + return historyPages.isEmpty ? const [] : historyPages.removeAt(0); + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + filters.add(filter); + _listeners.add(onEvent); + if (onClosed != null) { + _closedListeners.add(onClosed); + } + return () { + filters.remove(filter); + _listeners.remove(onEvent); + if (onClosed != null) { + _closedListeners.remove(onClosed); + } + }; + } + + void emit(NostrEvent event) { + for (final listener in List.of(_listeners)) { + listener(event); + } + } +} + +class _FakeRelayConfigNotifier extends RelayConfigNotifier { + String? _nsec; + + _FakeRelayConfigNotifier({required String? nsec}) : _nsec = nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'http://localhost:3000', nsec: _nsec); + + void setIdentity(String? nsec) { + _nsec = nsec; + state = RelayConfig(baseUrl: 'http://localhost:3000', nsec: nsec); + } +} diff --git a/mobile/test/shared/crypto/nip44_interop_test.dart b/mobile/test/shared/crypto/nip44_interop_test.dart index be4e7d87bc7..f1095b9b42a 100644 --- a/mobile/test/shared/crypto/nip44_interop_test.dart +++ b/mobile/test/shared/crypto/nip44_interop_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:buzz/shared/crypto/nip44.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -18,4 +20,12 @@ void main() { '{"version":1,"theme":"catppuccin-latte","accent":"#f97316","followSystem":false}', ); }); + + test('rejects oversized ciphertext before base64 allocation', () { + final oversized = List.filled(nip44MaxContentLength + 1, 'A').join(); + expect( + () => nip44Decrypt(Uint8List(32), oversized), + throwsA(isA()), + ); + }); }