diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b1a2d5623b2..a786a361b7f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -144,6 +144,8 @@ export default defineConfig({ "**/inbox-live-update.spec.ts", "**/mesh-compute.spec.ts", "**/observer-archive-policy.spec.ts", + "**/agent-usage.spec.ts", + "**/agent-usage-screenshots.spec.ts", "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", @@ -151,6 +153,7 @@ export default defineConfig({ "**/huddle-transcription.spec.ts", "**/agent-numeric-tuning.spec.ts", "**/needs-restart-screenshots.spec.ts", + "**/provider-usage.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index 9c9aa58c1fd..f840d3d6587 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -35,6 +35,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { commands: &["goose"], aliases: &[], avatar_url: "", + provider_usage_id: None, mcp_command: None, mcp_hooks: false, underlying_cli: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 95e9759f10e..089715f80bd 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -137,11 +137,11 @@ pub async fn save_custom_harness( let default_args = crate::managed_agents::normalize_agent_args(&definition.command, definition.args.clone()); - Ok(AcpRuntimeCatalogEntry { id: definition.id, label: definition.label, avatar_url: String::new(), + provider_usage_id: None, availability, command: command_opt, binary_path, diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7cb2d8e3b83..f9e37bd4cd7 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -59,6 +59,7 @@ mod project_git_types; mod project_git_workflow; mod project_repo_paths; mod project_terminal; +mod provider_usage; mod qr_download; mod relay_members; mod relay_reconnect; @@ -115,6 +116,7 @@ pub use project_git_file_content::*; pub use project_git_recipient_notes::*; pub use project_git_workflow::*; pub use project_terminal::*; +pub use provider_usage::*; pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; diff --git a/desktop/src-tauri/src/commands/provider_usage.rs b/desktop/src-tauri/src/commands/provider_usage.rs new file mode 100644 index 00000000000..af293341db5 --- /dev/null +++ b/desktop/src-tauri/src/commands/provider_usage.rs @@ -0,0 +1,725 @@ +use serde::Serialize; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_FRAME_BYTES: usize = 256 * 1024; +const MAX_TOTAL_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ProviderUsageId { + Codex, + Claude, + Grok, +} + +impl ProviderUsageId { + fn parse(value: &str) -> Option { + match value { + "codex" => Some(Self::Codex), + "claude" => Some(Self::Claude), + "grok" => Some(Self::Grok), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Claude => "claude", + Self::Grok => "grok", + } + } +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// A provider shown in the provider-allowance experiment picker. +pub struct ProviderUsageCapability { + id: &'static str, + name: &'static str, + availability: &'static str, + detail: &'static str, +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One independently resetting personal-allowance window. +pub struct ProviderUsageWindow { + id: String, + label: String, + used_percent: u64, + remaining_percent: u64, + resets_at: Option, + duration_minutes: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Optional provider totals that are not tied to one allowance window. +pub struct ProviderUsageTotals { + credit_balance: Option, + reset_credits_available: Option, + lifetime_tokens: Option, + latest_daily_tokens: Option, + latest_daily_date: Option, +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +/// A normalized local personal-allowance snapshot safe to expose over IPC. +pub struct ProviderUsageSnapshot { + provider: &'static str, + vendor: &'static str, + product: &'static str, + source: &'static str, + plan_type: Option, + windows: Vec, + totals: ProviderUsageTotals, + fetched_at: u64, +} + +#[tauri::command] +/// Lists supported and explicitly unsupported personal-allowance adapters. +pub async fn list_provider_usage_capabilities() -> Vec { + let codex_availability = if crate::managed_agents::resolve_command("codex").is_some() { + ("available", "Uses your existing local Codex sign-in") + } else { + ("not_installed", "Install and sign in to the Codex CLI") + }; + + vec![ + ProviderUsageCapability { + id: ProviderUsageId::Codex.as_str(), + name: "Codex", + availability: codex_availability.0, + detail: codex_availability.1, + }, + ProviderUsageCapability { + id: ProviderUsageId::Claude.as_str(), + name: "Claude", + availability: "unsupported", + detail: "No supported standalone personal allowance reader yet", + }, + ProviderUsageCapability { + id: ProviderUsageId::Grok.as_str(), + name: "Grok", + availability: "unsupported", + detail: "Consumer allowance is available in Grok Settings", + }, + ] +} + +#[tauri::command] +/// Reads a normalized personal-allowance snapshot for a selected provider. +pub async fn get_provider_usage(provider: String) -> Result { + let provider = + ProviderUsageId::parse(&provider).ok_or_else(|| "provider_usage_unknown".to_string())?; + match provider { + ProviderUsageId::Codex => { + let codex_path = crate::managed_agents::resolve_command("codex") + .ok_or_else(|| "codex_not_installed".to_string())?; + tokio::task::spawn_blocking(move || read_codex_provider_usage(&codex_path)) + .await + .map_err(|_| "codex_usage_task_failed".to_string())? + } + ProviderUsageId::Claude => Err("claude_usage_unsupported".to_string()), + ProviderUsageId::Grok => Err("grok_usage_unsupported".to_string()), + } +} + +fn read_codex_provider_usage( + codex_path: &std::path::Path, +) -> Result { + let mut command = Command::new(codex_path); + command + .args(["app-server", "--stdio"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // App-server stderr can contain local configuration details. Only + // stable error codes cross the command boundary. + .stderr(Stdio::null()); + if let Some(workdir) = crate::managed_agents::default_agent_workdir() { + command.current_dir(workdir); + } + if let Some(path) = crate::managed_agents::login_shell_path() { + command.env("PATH", path); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Keep launchers and native descendants together so every exit path + // can reap the complete local app-server process tree. + command.process_group(0); + } + crate::util::configure_no_window(&mut command); + + let mut child = command + .spawn() + .map_err(|_| "codex_app_server_start_failed".to_string())?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| finish_with_error(&mut child, "codex_app_server_stdin_unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| finish_with_error(&mut child, "codex_app_server_stdout_unavailable"))?; + + write_message(&mut child, &mut stdin, &initialize_request())?; + + let (sender, receiver) = mpsc::channel(); + let mut reader = Some(std::thread::spawn(move || { + read_bounded_jsonl(stdout, sender) + })); + let deadline = Instant::now() + RESPONSE_TIMEOUT; + + if let Err(code) = wait_for_response(&receiver, deadline, 1) { + stop_child(&mut child); + join_reader(&mut reader); + return Err(code); + } + + let account_requests = post_initialize_requests(); + for message in &account_requests[..2] { + if let Err(code) = write_message(&mut child, &mut stdin, message) { + join_reader(&mut reader); + return Err(code); + } + } + + let rate_limits = match wait_for_response(&receiver, deadline, 2) { + Ok(result) => result, + Err(code) => { + stop_child(&mut child); + join_reader(&mut reader); + return Err(code); + } + }; + // Token totals are supplemental. Older app-server versions and some + // accounts expose rate limits without account/usage/read; keep the valid + // allowance snapshot and leave optional totals empty in that case. + let token_usage = if write_message(&mut child, &mut stdin, &account_requests[2]).is_ok() { + wait_for_optional_response(&receiver, deadline, 3) + } else { + json!({}) + }; + + stop_child(&mut child); + join_reader(&mut reader); + normalize_usage(&rate_limits, &token_usage, unix_timestamp()) +} + +fn join_reader(reader: &mut Option>) { + if let Some(reader) = reader.take() { + let _ = reader.join(); + } +} + +fn write_message(child: &mut Child, stdin: &mut impl Write, message: &Value) -> Result<(), String> { + writeln!(stdin, "{message}") + .and_then(|_| stdin.flush()) + .map_err(|_| finish_with_error(child, "codex_app_server_write_failed")) +} + +fn initialize_request() -> Value { + json!({ + "method": "initialize", + "id": 1, + "params": { + "clientInfo": { + "name": "buzz_desktop", + "title": "Buzz Desktop", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { + "optOutNotificationMethods": [ + "thread/started", + "item/agentMessage/delta" + ] + } + } + }) +} + +fn post_initialize_requests() -> [Value; 3] { + [ + json!({"method": "initialized", "params": {}}), + json!({"method": "account/rateLimits/read", "id": 2, "params": null}), + json!({"method": "account/usage/read", "id": 3, "params": null}), + ] +} + +fn read_bounded_jsonl(stdout: impl Read, sender: mpsc::Sender>) { + let mut reader = BufReader::new(stdout); + let mut total = 0_usize; + let mut frame = Vec::new(); + loop { + let buffer = match reader.fill_buf() { + Ok(buffer) => buffer, + Err(_) => { + let _ = sender.send(Err("codex_app_server_read_failed".to_string())); + break; + } + }; + if buffer.is_empty() { + if !frame.is_empty() { + let _ = sender.send(Err("codex_app_server_read_failed".to_string())); + } + break; + } + + let newline = buffer.iter().position(|byte| *byte == b'\n'); + let bytes = newline.map_or(buffer.len(), |position| position + 1); + total = total.saturating_add(bytes); + if frame.len().saturating_add(bytes) > MAX_FRAME_BYTES || total > MAX_TOTAL_BYTES { + let _ = sender.send(Err("codex_usage_response_too_large".to_string())); + break; + } + frame.extend_from_slice(&buffer[..bytes]); + reader.consume(bytes); + + if newline.is_none() { + continue; + } + while matches!(frame.last(), Some(b'\n' | b'\r')) { + frame.pop(); + } + let completed = std::mem::take(&mut frame); + match String::from_utf8(completed) { + Ok(line) => { + if sender.send(Ok(line)).is_err() { + break; + } + } + Err(_) => { + let _ = sender.send(Err("codex_app_server_read_failed".to_string())); + break; + } + } + } +} + +fn wait_for_response( + receiver: &mpsc::Receiver>, + deadline: Instant, + expected_id: u64, +) -> Result { + loop { + let now = Instant::now(); + if now >= deadline { + return Err("codex_usage_timeout".to_string()); + } + match receiver.recv_timeout(deadline.saturating_duration_since(now)) { + Ok(Ok(line)) => { + let message = serde_json::from_str::(&line) + .map_err(|_| "codex_usage_invalid_response".to_string())?; + if message.get("id").and_then(Value::as_u64) != Some(expected_id) { + continue; + } + if let Some(error_code) = response_error_code(&message) { + return Err(error_code); + } + return message + .get("result") + .cloned() + .ok_or_else(|| "codex_usage_invalid_response".to_string()); + } + Ok(Err(code)) => return Err(code), + Err(mpsc::RecvTimeoutError::Timeout) => return Err("codex_usage_timeout".to_string()), + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err("codex_app_server_closed".to_string()) + } + } + } +} + +fn wait_for_optional_response( + receiver: &mpsc::Receiver>, + deadline: Instant, + expected_id: u64, +) -> Value { + wait_for_response(receiver, deadline, expected_id).unwrap_or_else(|_| json!({})) +} + +fn response_error_code(message: &Value) -> Option { + let error = message.get("error")?; + let detail = error + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if detail.contains("auth") || detail.contains("login") { + return Some("codex_not_authenticated".to_string()); + } + if detail.contains("experimental") || detail.contains("method") { + return Some("codex_usage_protocol_unsupported".to_string()); + } + Some("codex_usage_unavailable".to_string()) +} + +fn normalize_usage( + rate_limits_result: &Value, + token_usage_result: &Value, + fetched_at: u64, +) -> Result { + let bucket_map = rate_limits_result + .get("rateLimitsByLimitId") + .and_then(Value::as_object); + let snapshots: Vec<(&str, &Value)> = match bucket_map { + Some(map) if !map.is_empty() => { + map.iter().map(|(id, value)| (id.as_str(), value)).collect() + } + _ => vec![( + "codex", + rate_limits_result + .get("rateLimits") + .ok_or_else(|| "codex_usage_invalid_response".to_string())?, + )], + }; + + let mut windows = Vec::new(); + for (fallback_id, snapshot) in &snapshots { + let limit_id = snapshot + .get("limitId") + .and_then(Value::as_str) + .unwrap_or(fallback_id); + let limit_name = snapshot + .get("limitName") + .and_then(Value::as_str) + .unwrap_or(limit_id); + for (window_id, default_label) in [("primary", "Primary"), ("secondary", "Secondary")] { + let Some(window) = snapshot.get(window_id).filter(|value| !value.is_null()) else { + continue; + }; + windows.push(normalize_window( + limit_id, + limit_name, + window_id, + default_label, + window, + )?); + } + } + if windows.is_empty() { + return Err("codex_usage_limit_unavailable".to_string()); + } + + let first_snapshot = snapshots.first().map(|(_, snapshot)| *snapshot); + let latest_bucket = token_usage_result + .get("dailyUsageBuckets") + .and_then(Value::as_array) + .and_then(|buckets| { + buckets.iter().max_by_key(|bucket| { + bucket + .get("startDate") + .and_then(Value::as_str) + .unwrap_or_default() + }) + }); + + Ok(ProviderUsageSnapshot { + provider: ProviderUsageId::Codex.as_str(), + vendor: "openai", + product: "codex", + source: "personalAllowance", + plan_type: rate_limits_result + .get("rateLimits") + .and_then(|snapshot| snapshot.get("planType")) + .and_then(Value::as_str) + .or_else(|| { + first_snapshot + .and_then(|snapshot| snapshot.get("planType")) + .and_then(Value::as_str) + }) + .map(str::to_owned), + windows, + totals: ProviderUsageTotals { + credit_balance: rate_limits_result + .get("rateLimits") + .and_then(|snapshot| snapshot.get("credits")) + .and_then(|credits| credits.get("balance")) + .and_then(Value::as_str) + .or_else(|| { + first_snapshot + .and_then(|snapshot| snapshot.get("credits")) + .and_then(|credits| credits.get("balance")) + .and_then(Value::as_str) + }) + .map(str::to_owned), + reset_credits_available: rate_limits_result + .get("rateLimitResetCredits") + .and_then(|credits| credits.get("availableCount")) + .and_then(Value::as_u64), + lifetime_tokens: token_usage_result + .get("summary") + .and_then(|summary| summary.get("lifetimeTokens")) + .and_then(Value::as_u64), + latest_daily_tokens: latest_bucket + .and_then(|bucket| bucket.get("tokens")) + .and_then(Value::as_u64), + latest_daily_date: latest_bucket + .and_then(|bucket| bucket.get("startDate")) + .and_then(Value::as_str) + .map(str::to_owned), + }, + fetched_at, + }) +} + +fn normalize_window( + limit_id: &str, + limit_name: &str, + window_id: &str, + default_label: &str, + window: &Value, +) -> Result { + let used_percent = window + .get("usedPercent") + .and_then(Value::as_i64) + .filter(|value| (0..=100).contains(value)) + .map(|value| value as u64) + .ok_or_else(|| "codex_usage_invalid_response".to_string())?; + let duration_minutes = window.get("windowDurationMins").and_then(Value::as_u64); + let window_label = match duration_minutes { + Some(300) => "5-hour".to_string(), + Some(1_440) => "Daily".to_string(), + Some(10_080) => "Weekly".to_string(), + Some(minutes) if minutes % 1_440 == 0 => format!("{}-day", minutes / 1_440), + Some(minutes) if minutes % 60 == 0 => format!("{}-hour", minutes / 60), + _ => default_label.to_string(), + }; + let label = if limit_name.eq_ignore_ascii_case("codex") { + window_label + } else { + format!("{limit_name} · {window_label}") + }; + Ok(ProviderUsageWindow { + id: format!("{limit_id}:{window_id}"), + label, + used_percent, + remaining_percent: 100 - used_percent, + resets_at: window.get("resetsAt").and_then(Value::as_i64), + duration_minutes, + }) +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn finish_with_error(child: &mut Child, code: &str) -> String { + stop_child(child); + code.to_string() +} + +fn stop_child(child: &mut Child) { + let _ = crate::managed_agents::terminate_process(child.id()); + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initializes_before_account_reads_without_experimental_flag() { + assert_eq!( + initialize_request().get("id").and_then(Value::as_u64), + Some(1) + ); + assert!(initialize_request() + .pointer("/params/capabilities/experimentalApi") + .is_none()); + let requests = post_initialize_requests(); + assert_eq!( + requests[0].get("method").and_then(Value::as_str), + Some("initialized") + ); + assert_eq!(requests[1].get("id").and_then(Value::as_u64), Some(2)); + assert_eq!(requests[2].get("id").and_then(Value::as_u64), Some(3)); + } + + #[test] + fn normalizes_all_multi_bucket_windows() { + let rate_limits = json!({ + "rateLimits": { + "primary": {"usedPercent": 38}, + "credits": {"balance": "0"}, + "planType": "pro" + }, + "rateLimitsByLimitId": { + "codex": { + "limitId": "codex", + "limitName": "Codex", + "primary": { + "usedPercent": 38, + "windowDurationMins": 300, + "resetsAt": 1785258777 + }, + "secondary": { + "usedPercent": 52, + "windowDurationMins": 10080, + "resetsAt": 1785658777 + } + }, + "review": { + "limitId": "review", + "limitName": "Code review", + "primary": {"usedPercent": 12} + } + }, + "rateLimitResetCredits": {"availableCount": 3} + }); + let tokens = json!({ + "summary": {"lifetimeTokens": 13597623776_u64}, + "dailyUsageBuckets": [ + {"startDate": "2026-07-23", "tokens": 373817016}, + {"startDate": "2026-07-24", "tokens": 61038450} + ] + }); + + let usage = normalize_usage(&rate_limits, &tokens, 123).unwrap(); + assert_eq!(usage.provider, "codex"); + assert_eq!(usage.plan_type.as_deref(), Some("pro")); + assert_eq!(usage.windows.len(), 3); + assert_eq!(usage.windows[0].remaining_percent, 62); + assert_eq!(usage.windows[1].remaining_percent, 48); + assert_eq!(usage.windows[0].label, "5-hour"); + assert_eq!(usage.windows[1].label, "Weekly"); + assert_eq!(usage.windows[2].label, "Code review · Primary"); + assert_eq!(usage.totals.credit_balance.as_deref(), Some("0")); + assert_eq!(usage.totals.reset_credits_available, Some(3)); + assert_eq!(usage.totals.lifetime_tokens, Some(13_597_623_776)); + assert_eq!(usage.totals.latest_daily_tokens, Some(61_038_450)); + assert_eq!(usage.fetched_at, 123); + } + + #[test] + fn keeps_rate_limits_when_optional_token_usage_fails() { + let (sender, receiver) = mpsc::channel(); + sender + .send(Ok(json!({ + "id": 3, + "error": { + "code": -32601, + "message": "Method not found" + } + }) + .to_string())) + .unwrap(); + let token_usage = + wait_for_optional_response(&receiver, Instant::now() + Duration::from_secs(1), 3); + let usage = normalize_usage( + &json!({ + "rateLimits": { + "primary": {"usedPercent": 38}, + "planType": "pro" + } + }), + &token_usage, + 123, + ) + .unwrap(); + + assert_eq!(usage.windows[0].remaining_percent, 62); + assert_eq!(usage.plan_type.as_deref(), Some("pro")); + assert_eq!(usage.totals.lifetime_tokens, None); + assert_eq!(usage.totals.latest_daily_tokens, None); + } + + #[test] + fn normalizes_map_only_rate_limits() { + let usage = normalize_usage( + &json!({ + "rateLimitsByLimitId": { + "codex": { + "limitId": "codex", + "limitName": "Codex", + "planType": "pro", + "primary": { + "usedPercent": 25, + "windowDurationMins": 300 + } + } + } + }), + &json!({}), + 123, + ) + .unwrap(); + + assert_eq!(usage.windows.len(), 1); + assert_eq!(usage.windows[0].remaining_percent, 75); + assert_eq!(usage.plan_type.as_deref(), Some("pro")); + } + + #[test] + fn rejects_out_of_range_percentage() { + let rate_limits = json!({ + "rateLimits": {"primary": {"usedPercent": 140}} + }); + assert_eq!( + normalize_usage(&rate_limits, &json!({}), 1).unwrap_err(), + "codex_usage_invalid_response" + ); + } + + #[test] + fn maps_auth_errors_without_forwarding_details() { + let response = json!({ + "id": 2, + "error": { + "code": -32000, + "message": "Login required for alice@example.com" + } + }); + assert_eq!( + response_error_code(&response).as_deref(), + Some("codex_not_authenticated") + ); + } + + #[test] + fn response_reader_correlates_ids_and_ignores_notifications() { + let (sender, receiver) = mpsc::channel(); + sender + .send(Ok(json!({"method": "account/updated"}).to_string())) + .unwrap(); + sender + .send(Ok(json!({"id": 2, "result": {"ok": true}}).to_string())) + .unwrap(); + assert_eq!( + wait_for_response(&receiver, Instant::now() + Duration::from_secs(1), 2).unwrap(), + json!({"ok": true}) + ); + } + + #[test] + fn response_reader_rejects_malformed_json() { + let (sender, receiver) = mpsc::channel(); + sender.send(Ok("{not-json".to_string())).unwrap(); + assert_eq!( + wait_for_response(&receiver, Instant::now() + Duration::from_secs(1), 1).unwrap_err(), + "codex_usage_invalid_response" + ); + } + + #[test] + fn bounded_reader_rejects_oversized_frame() { + let oversized = vec![b'a'; MAX_FRAME_BYTES + 1]; + let (sender, receiver) = mpsc::channel(); + read_bounded_jsonl(oversized.as_slice(), sender); + assert_eq!( + receiver.recv().unwrap().unwrap_err(), + "codex_usage_response_too_large" + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c120ac12679..99af93c1ec2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -553,6 +553,8 @@ pub fn run() { transfer_builderlab_community, title_bar_double_click, get_identity, + list_provider_usage_capabilities, + get_provider_usage, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 36b6022b53b..8ca2af8e004 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -34,6 +34,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { commands: &["goose"], aliases: &[], avatar_url: "", + provider_usage_id: None, mcp_command: None, mcp_hooks: false, underlying_cli: None, @@ -625,6 +626,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { commands: &["buzz-agent"], aliases: &[], avatar_url: "", + provider_usage_id: None, mcp_command: None, mcp_hooks: false, underlying_cli: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 78592357c9b..75d420a0eaf 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -72,9 +72,7 @@ fn common_binary_paths() -> &'static [PathBuf] { .join("bin"), ); } - // Goose's legacy Windows installer (superseded by #2680) unpacked - // to %USERPROFILE%\goose\goose.exe, which is on no standard PATH — - // without this probe those installs stay permanently undiscovered. + // Probe Goose's legacy off-PATH %USERPROFILE%\goose install. if let Some(profile) = std::env::var_os("USERPROFILE") { paths.push(PathBuf::from(profile).join("goose")); } @@ -90,6 +88,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ commands: &["goose"], aliases: &[], avatar_url: GOOSE_AVATAR_URL, + provider_usage_id: None, mcp_command: None, mcp_hooks: false, underlying_cli: Some("goose"), @@ -125,6 +124,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ commands: &["claude-agent-acp", "claude-code-acp"], aliases: &["claude-code", "claudecode"], avatar_url: CLAUDE_CODE_AVATAR_URL, + provider_usage_id: None, mcp_command: None, mcp_hooks: false, underlying_cli: Some("claude"), @@ -158,6 +158,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ commands: &["codex-acp"], aliases: &[], avatar_url: CODEX_AVATAR_URL, + provider_usage_id: Some("codex"), mcp_command: Some("buzz-dev-mcp"), mcp_hooks: false, underlying_cli: Some("codex"), @@ -183,7 +184,6 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_rounds_env_var: None, required_normalized_fields: &[], login_hint: Some("Run `codex login` to authenticate."), - // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, KnownAcpRuntime { @@ -192,6 +192,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ commands: &["buzz-agent"], aliases: &[], avatar_url: BUZZ_AGENT_AVATAR_URL, + provider_usage_id: None, mcp_command: Some("buzz-dev-mcp"), mcp_hooks: true, underlying_cli: None, @@ -1198,9 +1199,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) - | AcpAvailabilityStatus::NotInstalled => runtime.cli_install_instructions_url, }; - // node_required now means Buzz cannot provide npm for this platform. - // On supported desktop platforms, Buzz downloads a private Node/npm - // runtime into app data before running npm-backed adapter installs. + // True only when Buzz cannot provide npm for this platform. let node_required = matches!( availability, AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled @@ -1215,6 +1214,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime, force: bool) - id: runtime.id.to_string(), label: runtime.label.to_string(), avatar_url: runtime.avatar_url.to_string(), + provider_usage_id: runtime.provider_usage_id.map(str::to_string), availability, command, binary_path, @@ -1350,9 +1350,9 @@ pub fn discover_acp_runtimes_from( entries.push(AcpRuntimeCatalogEntry { id: def.id.clone(), label: def.label.clone(), - // F1 security fix: never copy user-supplied avatar URL into the catalog. - // All icons are bundled assets; customs fall back to TerminalSquare in the UI. + // Never copy user-supplied avatar URLs into the catalog. avatar_url: String::new(), + provider_usage_id: None, availability, command, binary_path, diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index fd853094515..833e4e69b50 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -56,6 +56,7 @@ pub(super) fn preset_catalog_entry( label: def.label.to_string(), // No remote URL — all preset icons are bundled assets. avatar_url: String::new(), + provider_usage_id: None, availability, command, binary_path, diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 34edecdcd9c..57733c52909 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -5,6 +5,9 @@ pub(crate) struct KnownAcpRuntime { pub commands: &'static [&'static str], pub aliases: &'static [&'static str], pub avatar_url: &'static str, + /// Personal-allowance adapter exposed by this runtime, when Buzz has a + /// safe standalone reader for the underlying account. + pub provider_usage_id: Option<&'static str>, /// Legacy MCP server binary field. Vestigial — all agents now use the bundled CLI /// directly. Will be removed when runtime discovery is simplified. pub mcp_command: Option<&'static str>, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index f7f5d5c5d0e..1967b7838fd 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1017,8 +1017,7 @@ mod tests { // ── cli_login_requirements: resolve_command integration ───────────── - /// Construct a minimal `KnownAcpRuntime` stub for testing cli_login_requirements. - /// `commands` are the adapter binaries; `underlying_cli` is the CLI name. + /// Minimal runtime stub for cli-login requirement tests. fn make_cli_runtime( commands: &'static [&'static str], underlying_cli: Option<&'static str>, @@ -1029,6 +1028,7 @@ mod tests { commands, aliases: &[], avatar_url: "", + provider_usage_id: None, mcp_command: None, mcp_hooks: false, underlying_cli, @@ -1206,8 +1206,7 @@ mod tests { // ── codex readiness version gate ─────────────────────────────────────── - /// Build a minimal `KnownAcpRuntime` for testing the codex version gate. - /// `adapter_commands` are the exact strings passed to `find_command` — use + /// `adapter_commands` are the exact strings passed to `find_command`: use /// `&["codex-acp"]` when the binary is on PATH, or `&[]` /// when resolving via absolute path. `underlying_cli` is a portable /// stand-in so the adapter is not misclassified as `CliMissing`. @@ -1221,6 +1220,7 @@ mod tests { commands: adapter_commands, aliases: &[], avatar_url: "", + provider_usage_id: None, mcp_command: None, mcp_hooks: false, underlying_cli, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 9049482de3a..020f637eb48 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -619,6 +619,9 @@ pub struct AcpRuntimeCatalogEntry { pub id: String, pub label: String, pub avatar_url: String, + /// Provider-neutral personal-allowance adapter associated with this + /// runtime. Absent when no safe reader exists. + pub provider_usage_id: Option, pub availability: AcpAvailabilityStatus, pub command: Option, pub binary_path: Option, diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 35b5e4b2093..b2da7b45461 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -8,6 +8,8 @@ import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; import { cn } from "@/shared/lib/cn"; import { topChromeBackdrop } from "@/shared/layout/chromeLayout"; import { useOptionalSidebar } from "@/shared/ui/sidebar"; +import { FeatureGate } from "@/shared/features"; +import { SidebarProviderUsageIndicator } from "@/features/provider-usage/ui/SidebarProviderUsageIndicator"; type AppTopChromeProps = { canGoBack: boolean; @@ -160,6 +162,9 @@ export function AppTopChrome({ data-tauri-drag-region id="app-top-chrome-content" /> + + + ); } diff --git a/desktop/src/features/agent-usage/hooks.test.mjs b/desktop/src/features/agent-usage/hooks.test.mjs new file mode 100644 index 00000000000..36c82a05fc9 --- /dev/null +++ b/desktop/src/features/agent-usage/hooks.test.mjs @@ -0,0 +1,346 @@ +/** + * Fake-timer proof for `useLocalDayBoundaries`: verifies it schedules exactly + * one `setTimeout` per local midnight, rebuilding boundaries and rescheduling + * the next fire each time — never `setInterval` (which would drift across DST). + * Uses `node:test`'s `mock.timers` to drive the wall clock deterministically. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { mock } from "node:test"; + +function installDOMShim() { + class EventTargetShim { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener(type, listener) { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((l) => l !== listener), + ); + } + + dispatchEvent(event) { + for (const listener of this.listeners.get(event.type) ?? []) + listener(event); + return true; + } + } + + class NodeShim extends EventTargetShim { + constructor(tagName) { + super(); + this.tagName = tagName; + this.nodeName = tagName.toUpperCase(); + this.nodeType = 1; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + this.children = []; + this.childNodes = []; + this.style = {}; + this.parentNode = null; + } + + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.children[0] ?? null; + } + get lastChild() { + return this.children.at(-1) ?? null; + } + get nextSibling() { + return null; + } + get nodeValue() { + return null; + } + + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + child.parentNode = this; + return child; + } + + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + child.parentNode = null; + return child; + } + + insertBefore(child, reference) { + if (!reference) return this.appendChild(child); + const index = this.children.indexOf(reference); + if (index < 0) return this.appendChild(child); + this.children.splice(index, 0, child); + this.childNodes.splice(index, 0, child); + child.parentNode = this; + return child; + } + + contains(node) { + return ( + this === node || this.children.some((child) => child.contains(node)) + ); + } + } + + class DocumentShim extends EventTargetShim { + constructor() { + super(); + this.nodeType = 9; + this.defaultView = globalThis; + } + + createElement(tagName) { + return new NodeShim(tagName); + } + + createTextNode(value) { + const node = new NodeShim("#text"); + node.nodeType = 3; + node.nodeValue = value; + return node; + } + + createComment(value) { + const node = new NodeShim("#comment"); + node.nodeType = 8; + node.nodeValue = value; + return node; + } + + get activeElement() { + return null; + } + } + + globalThis.document = new DocumentShim(); + globalThis.HTMLIFrameElement = NodeShim; + globalThis.HTMLElement = NodeShim; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + // React's scheduler uses MessageChannel (native in Node) as a fallback, so + // mocking setTimeout/Date cannot stall commits — rAF just needs to exist. + globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + globalThis.CSS = { escape: (value) => value }; +} + +installDOMShim(); + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { useLocalDayBoundaries } from "./hooks.ts"; + +function Harness({ onBoundaries, range }) { + const boundaries = useLocalDayBoundaries(range); + onBoundaries(boundaries); + return null; +} + +const SEVEN_DAY_RANGE = { kind: "preset", days: 7 }; + +/** Local midnight as unix seconds, the unit the hook emits. */ +function midnight(year, monthIndex, day) { + return Math.floor( + new Date(year, monthIndex, day, 0, 0, 0, 0).getTime() / 1_000, + ); +} + +/** + * Mounts the hook with `initialRange` and calls `run` with a live view: + * `boundaries` = newest emission, `renders` = emission count, + * `render(range)` re-renders, `tick(ms)` advances the mocked clock. + */ +async function withMountedHook(initialRange, run) { + const emissions = []; + const root = createRoot(document.createElement("div")); + const render = async (range) => { + await act(async () => { + root.render( + React.createElement(Harness, { + onBoundaries: (boundaries) => emissions.push(boundaries), + range, + }), + ); + }); + }; + + try { + await render(initialRange); + await run({ + emissions, + get boundaries() { + return emissions.at(-1); + }, + get renders() { + return emissions.length; + }, + render, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + }, + /** Advance the mocked wall clock, flushing any React work it triggers. */ + tick: async (ms) => { + await act(async () => { + mock.timers.tick(ms); + }); + }, + }); + } finally { + await act(async () => { + root.unmount(); + }); + } +} + +/** Runs `fn` with the wall clock frozen one minute before a local midnight. */ +async function atOneMinuteToMidnight(fn) { + mock.timers.enable({ + apis: ["setTimeout", "Date"], + now: new Date(2026, 5, 15, 23, 59, 0).getTime(), + }); + try { + await fn(); + } finally { + mock.timers.reset(); + } +} + +test("useLocalDayBoundaries reschedules across two local-midnight rollovers, rebuilding boundaries each time", async () => { + await atOneMinuteToMidnight(async () => { + await withMountedHook(SEVEN_DAY_RANGE, async (hook) => { + assert.equal( + hook.boundaries.length, + 8, + "7-day window yields 8 boundaries", + ); + const initial = hook.boundaries; + + // Crosses the Jun 16 local midnight. The single scheduled `setTimeout` + // must fire, bump `rolloverTick`, and rebuild the boundary set. + await hook.tick(60_000); + + assert.notDeepEqual( + hook.boundaries, + initial, + "boundaries must rebuild after the first midnight rollover fires", + ); + assert.equal( + hook.boundaries.length, + 8, + "boundary count is unchanged by a rollover", + ); + assert.equal( + hook.boundaries.at(-1), + midnight(2026, 5, 17), + "newest boundary must shift forward to tomorrow of the new window", + ); + const afterFirst = hook.boundaries; + + // Crossing the Jun 17 local midnight only fires if the first rollover's + // effect RESCHEDULED a fresh `setTimeout` rather than going silent. + await hook.tick(24 * 60 * 60 * 1_000); + + assert.notDeepEqual( + hook.boundaries, + afterFirst, + "boundaries must rebuild again after the second rollover, proving the timer rescheduled itself", + ); + assert.equal( + hook.boundaries.at(-1), + midnight(2026, 5, 18), + "newest boundary must shift forward again after the rescheduled rollover", + ); + }); + }); +}); + +test("useLocalDayBoundaries clears its scheduled timeout on unmount (no post-unmount rollover)", async () => { + await atOneMinuteToMidnight(async () => { + await withMountedHook(SEVEN_DAY_RANGE, async (hook) => { + const rendersAtUnmount = hook.renders; + await hook.unmount(); + + // Crossing the midnight the pending timeout targeted must not throw or + // invoke a setState-after-unmount path — `clearTimeout` in the effect's + // cleanup must have already cancelled it. + await hook.tick(60_000); + + assert.equal( + hook.renders, + rendersAtUnmount, + "no render (and no error) after the component unmounted", + ); + }); + }); +}); + +test("useLocalDayBoundaries returns the same boundary array when re-rendered with an equal range literal", async () => { + await withMountedHook({ kind: "preset", days: 7 }, async (hook) => { + // A fresh object literal each render — the memo must key on the range's + // fields, not its identity, or every render would refetch. + await hook.render({ kind: "preset", days: 7 }); + + assert.ok(hook.renders >= 2, "component rendered at least twice"); + assert.equal( + hook.boundaries, + hook.emissions[0], + "an equal range literal must reuse the memoized boundary array", + ); + }); +}); + +test("useLocalDayBoundaries rebuilds when the range changes to a custom range", async () => { + await withMountedHook(SEVEN_DAY_RANGE, async (hook) => { + assert.equal(hook.boundaries.length, 8); + + await hook.render({ + kind: "custom", + startDate: "2026-01-01", + endDate: "2026-01-03", + }); + + assert.equal( + hook.boundaries.length, + 4, + "a 3-day custom range yields 4 boundaries closing the final day", + ); + assert.equal( + hook.boundaries[0], + midnight(2026, 0, 1), + "first boundary opens the requested start date in local time", + ); + }); +}); + +test("useLocalDayBoundaries returns no boundaries for an invalid custom range", async () => { + // Inverted range — `useAgentUsageSeries` gates its query on + // `boundaries.length >= 2`, so this must issue no request rather than one + // the backend would reject. + const invertedRange = { + kind: "custom", + startDate: "2026-03-10", + endDate: "2026-03-01", + }; + await withMountedHook(invertedRange, async (hook) => { + assert.deepEqual(hook.boundaries, []); + }); +}); diff --git a/desktop/src/features/agent-usage/hooks.ts b/desktop/src/features/agent-usage/hooks.ts new file mode 100644 index 00000000000..ebd5e55c5ff --- /dev/null +++ b/desktop/src/features/agent-usage/hooks.ts @@ -0,0 +1,143 @@ +import * as React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + getAgentUsageSeries, + onAgentMetricsChanged, + type AgentUsageSeries, +} from "@/shared/api/tauriArchive"; +import { + buildRangeBoundaries, + DEFAULT_USAGE_RANGE, + deriveUsageIngressTrailing, + msUntilNextLocalMidnight, + type UsageRange, +} from "./lib/agentUsage"; + +/** Root query key for the whole `agent-usage` family — invalidated en masse on any agent-metric change (M4/A13). */ +export const agentUsageQueryKeyRoot = ["agent-usage"] as const; + +/** + * Stable key incorporating the exact boundary set (so a midnight rollover or + * 7d/30d switch produces a new cache entry) and the optional author filter. + */ +export function agentUsageQueryKey( + boundaries: readonly number[], + agentPubkey?: string, +) { + return [ + ...agentUsageQueryKeyRoot, + boundaries.join(","), + agentPubkey ?? null, + ] as const; +} + +/** + * Local-day boundaries for the given range that recompute automatically at + * every local midnight (M4) — no `setInterval` (which would drift across + * DST), just a single scheduled `setTimeout` that reschedules itself each + * time it fires. Split out of {@link useAgentUsageSeries} so the rollover + * mechanics are testable without a `QueryClientProvider`. + * + * A custom range is anchored to explicit dates, so midnight rollover leaves + * it unchanged; the timer still runs so a later switch back to a preset is + * immediately correct. + */ +export function useLocalDayBoundaries(range: UsageRange): number[] { + // Bumped once per local midnight so `boundaries` below recomputes even + // though `range` hasn't changed. + const [rolloverTick, setRolloverTick] = React.useState(0); + + // `rolloverTick` is the only intended dependency: each fire reschedules + // against a freshly computed `Date.now()`, never a fixed interval that + // would drift across DST. + // biome-ignore lint/correctness/useExhaustiveDependencies: rolloverTick is read to reschedule, not to avoid a stale closure + React.useEffect(() => { + const timeoutId = setTimeout(() => { + setRolloverTick((tick) => tick + 1); + }, msUntilNextLocalMidnight()); + return () => clearTimeout(timeoutId); + }, [rolloverTick]); + + // Depend on the range's fields rather than the object so a caller passing a + // fresh literal each render doesn't rebuild boundaries (and refetch) every + // time. `rolloverTick` intentionally forces a recompute at local midnight + // even though it carries no boundary data itself. + const rangeKey = serializeRange(range); + // biome-ignore lint/correctness/useExhaustiveDependencies: rangeKey stands in for `range`; rolloverTick drives recompute, not boundary data + return React.useMemo( + () => buildRangeBoundaries(range), + [rangeKey, rolloverTick], + ); +} + +/** Stable string identity for a range, for memo/query keys. */ +function serializeRange(range: UsageRange): string { + return range.kind === "preset" + ? `preset:${range.days}` + : `custom:${range.startDate}:${range.endDate}`; +} + +/** + * Local NIP-AM usage series for the Agents overview or a single agent's + * profile drill-in. Rebuilds boundaries once per local midnight (M4, no + * polling) and invalidates on `onAgentMetricsChanged` — new archived + * metrics, or a kind-44200 subscription toggle — instead of a refetch + * interval. + * + * An invalid custom range produces no boundaries; the query stays disabled + * rather than issuing a request the backend would reject. + */ +export function useAgentUsageSeries({ + agentPubkey, + range, + enabled = true, +}: { + agentPubkey?: string; + range: UsageRange; + enabled?: boolean; +}) { + const queryClient = useQueryClient(); + const boundaries = useLocalDayBoundaries(range); + + React.useEffect( + () => + onAgentMetricsChanged(() => { + void queryClient.invalidateQueries({ + queryKey: agentUsageQueryKeyRoot, + }); + }), + [queryClient], + ); + + return useQuery({ + queryKey: agentUsageQueryKey(boundaries, agentPubkey), + queryFn: () => + getAgentUsageSeries({ bucketBoundaries: boundaries, agentPubkey }), + enabled: enabled && boundaries.length >= 2, + staleTime: 60_000, + gcTime: 5 * 60_000, + }); +} + +/** + * Trailing text for the profile Info tab's usage ingress row (plan:328). + * + * Owns its own 7-day window deliberately: the row summarises recent usage + * independently of the focused view's 7d/30d selector, so the two must not + * share a query. Returns `undefined` while the query is disabled or still + * loading, which the row renders as no trailing text at all. + * + * `enabled` gates the query off entirely when the row won't render. + */ +export function useUsageIngress( + agentPubkey: string | null, + enabled: boolean, +): string | undefined { + const query = useAgentUsageSeries({ + agentPubkey: agentPubkey ?? undefined, + range: DEFAULT_USAGE_RANGE, + enabled, + }); + return query.data ? deriveUsageIngressTrailing(query.data) : undefined; +} diff --git a/desktop/src/features/agent-usage/lib/agentUsage.test.mjs b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs new file mode 100644 index 00000000000..5ede4c86987 --- /dev/null +++ b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs @@ -0,0 +1,887 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bigintRatio, + buildCustomDayBoundaries, + buildLocalDayBoundaries, + buildRangeBoundaries, + countRangeDays, + describeRange, + deriveDisplayTotal, + deriveUsageIngressTrailing, + formatCoverageDate, + formatEstimatedCostUsd, + formatLocalDate, + formatModelCacheBreakdown, + formatTokenCountCompact, + formatTokenCountExact, + hasKnownCacheData, + isPartialField, + isUnknownField, + MAX_RANGE_DAYS, + msUntilNextLocalMidnight, + parseLocalDate, + parseTokenCount, + sortAgentsByDisplayTotal, + sortModelsByDisplayTotal, + sumKnownBucketTotals, + validateCustomRange, +} from "./agentUsage.ts"; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function usageField(overrides = {}) { + return { value: null, incomplete: false, ...overrides }; +} + +function reportedUsage(overrides = {}) { + return { + inputTokens: usageField(), + outputTokens: usageField(), + totalTokens: usageField(), + estimatedCostUsd: usageField(), + cacheReadTokens: usageField(), + cacheWriteTokens: usageField(), + freshInputTokens: usageField(), + ...overrides, + }; +} + +function agentUsage(pubkey, totalTokensValue, overrides = {}) { + return { + agentPubkey: pubkey, + usage: reportedUsage({ + totalTokens: usageField({ value: totalTokensValue }), + }), + buckets: [], + models: [], + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +function modelUsage(model, totalTokensValue, overrides = {}) { + return { + harness: null, + model, + usage: reportedUsage({ + totalTokens: usageField({ value: totalTokensValue }), + }), + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +// ── buildLocalDayBoundaries ────────────────────────────────────────────────── + +test("buildLocalDayBoundaries yields days+1 strictly increasing boundaries ending at tomorrow's local midnight", () => { + const now = new Date(2026, 5, 15, 14, 30, 0); // June 15, 2026, 14:30 local + for (const days of [7, 30]) { + const boundaries = buildLocalDayBoundaries(days, now); + assert.equal(boundaries.length, days + 1); + assertStrictlyIncreasing(boundaries); + assert.equal( + boundaries.at(-1), + Math.floor(new Date(2026, 5, 16, 0, 0, 0, 0).getTime() / 1000), + ); + } + // Result must be independent of time-of-day within the reference date. + assert.deepEqual( + buildLocalDayBoundaries(7, new Date(2026, 5, 15, 0, 0, 1)), + buildLocalDayBoundaries(7, new Date(2026, 5, 15, 23, 59, 59)), + ); +}); + +// ── TZ helpers ─────────────────────────────────────────────────────────────── +// `process.env.TZ` is read on every `Date` field access (not pinned at start), +// so mutating it inline is safe; `finally` restores the original value. + +function withTz(tz, fn) { + const original = process.env.TZ; + process.env.TZ = tz; + try { + return fn(); + } finally { + if (original === undefined) delete process.env.TZ; + else process.env.TZ = original; + } +} + +function assertStrictlyIncreasing(boundaries) { + for (let i = 1; i < boundaries.length; i++) { + assert.ok( + boundaries[i] > boundaries[i - 1], + `boundary ${i} (${boundaries[i]}) must exceed boundary ${i - 1} (${boundaries[i - 1]})`, + ); + } +} + +test("buildLocalDayBoundaries stays strictly increasing across DST transitions", () => { + const cases = [ + // Mar 12, after the Mar 10 spring-forward. + ["America/New_York", new Date(2024, 2, 12, 10, 0, 0), "Daylight"], + // Nov 6, after the Nov 3 fall-back. + ["America/New_York", new Date(2024, 10, 6, 10, 0, 0), "Standard"], + ]; + for (const [tz, now, marker] of cases) { + withTz(tz, () => { + assert.ok( + now.toString().includes(marker), + `sanity: expected ${marker} time for ${now.toString()}`, + ); + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); + assertStrictlyIncreasing(boundaries); + }); + } +}); + +test("buildLocalDayBoundaries emits days+1 distinct midnights across Pacific/Apia's skipped 2011-12-30 civil date", () => { + withTz("Pacific/Apia", () => { + // Samoa skipped 2011-12-30 when it crossed the Date Line (UTC-11→UTC+13); + // Dec 29 was immediately followed by Dec 31. Boundaries must not collapse + // the two distinct local midnights into one duplicate. + const now = new Date(2011, 11, 31, 12, 0, 0); + const boundaries = buildLocalDayBoundaries(7, now); + assert.equal(boundaries.length, 8); + assertStrictlyIncreasing(boundaries); + for (const [label, date] of [ + ["Dec 29", new Date(2011, 11, 29, 0, 0, 0)], + ["Dec 31", new Date(2011, 11, 31, 0, 0, 0)], + ]) { + assert.ok( + boundaries.includes(Math.floor(date.getTime() / 1000)), + `expected ${label} local midnight as a boundary`, + ); + } + + const boundaries30 = buildLocalDayBoundaries(30, now); + assert.equal(boundaries30.length, 31); + assertStrictlyIncreasing(boundaries30); + }); +}); + +// ── msUntilNextLocalMidnight ───────────────────────────────────────────────── + +test("msUntilNextLocalMidnight returns exact gap to the next local midnight, always positive", () => { + // Basic: 23:00 → 1h gap; exactly at midnight → 24h gap. + assert.equal( + msUntilNextLocalMidnight(new Date(2026, 5, 15, 23, 0, 0, 0)), + 60 * 60 * 1000, + ); + assert.equal( + msUntilNextLocalMidnight(new Date(2026, 5, 15, 0, 0, 0, 0)), + 24 * 60 * 60 * 1000, + ); + // DST/date-line TZs: must be positive and land exactly on local midnight. + for (const [tz, now] of [ + ["America/New_York", new Date(2024, 2, 9, 23, 30, 0)], // eve of spring-forward + ["Australia/Lord_Howe", new Date(2024, 9, 5, 23, 45, 0)], // eve of 30-min shift + ["Pacific/Apia", new Date(2011, 11, 29, 23, 0, 0)], // eve of the skipped date + ]) { + withTz(tz, () => { + const ms = msUntilNextLocalMidnight(now); + assert.ok(ms > 0, `${tz}: expected positive ms, got ${ms}`); + const landed = new Date(now.getTime() + ms); + assert.equal(landed.getHours(), 0, `${tz}: expected local midnight`); + assert.equal(landed.getMinutes(), 0, `${tz}: expected :00 minutes`); + }); + } +}); + +// ── parseTokenCount ────────────────────────────────────────────────────────── + +test("parseTokenCount parses decimal strings to bigint, preserving u64 precision", () => { + for (const [wire, expected] of [ + ["12345", 12345n], + ["0", 0n], + // Past Number.MAX_SAFE_INTEGER — a Number round-trip would lose digits. + ["18446744073709551615", 18446744073709551615n], + ]) { + assert.equal(parseTokenCount(wire), expected, wire); + } +}); + +test("parseTokenCount fails closed on null or malformed wire data instead of throwing", () => { + for (const malformed of [null, "", "-1", "1.5", "abc", "1e10", " 1", "1 "]) { + assert.equal( + parseTokenCount(malformed), + null, + `expected null for ${JSON.stringify(malformed)}`, + ); + } +}); + +// ── Formatters ────────────────────────────────────────────────────────────── + +test("formatTokenCountCompact abbreviates by magnitude and keeps the sign", () => { + for (const [count, expected] of [ + [999n, "999"], + [1_234n, "1.2K"], + [1_000_000n, "1M"], + [1_500_000_000n, "1.5B"], + [-1_234n, "-1.2K"], + ]) { + assert.equal(formatTokenCountCompact(count), expected); + } +}); + +test("formatTokenCountExact renders full grouped digits, never abbreviated", () => { + assert.equal(formatTokenCountExact(1_234_567n), "1,234,567"); + assert.equal(formatTokenCountExact(0n), "0"); +}); + +test("formatEstimatedCostUsd renders two-decimal USD currency", () => { + assert.equal(formatEstimatedCostUsd(1.5), "$1.50"); + assert.equal(formatEstimatedCostUsd(0), "$0.00"); +}); + +test("formatCoverageDate renders unknown for null and omits the year for real timestamps", () => { + assert.equal(formatCoverageDate(null), "unknown"); + const unixSeconds = 1_737_849_600; // 2025-01-26T00:00:00Z + const localDate = new Date(unixSeconds * 1000); + const formatted = formatCoverageDate(unixSeconds); + assert.match(formatted, new RegExp(`\\b${localDate.getDate()}\\b`)); + assert.doesNotMatch(formatted, new RegExp(`${localDate.getFullYear()}`)); +}); + +// ── bigintRatio ────────────────────────────────────────────────────────────── + +test("bigintRatio computes bounded ratios without losing bigint precision", () => { + const whole = 18_446_744_073_709_551_614n; // largest even value near u64::MAX + assert.equal(bigintRatio(whole / 2n, whole), 0.5); + for (const [part, w, expected] of [ + [5n, 0n, 0], + [5n, -10n, 0], + [-5n, 100n, 0], + [200n, 100n, 1], + ]) { + assert.equal(bigintRatio(part, w), expected, `${part}/${w}`); + } +}); + +// ── deriveDisplayTotal ──────────────────────────────────────────────────────── + +test("deriveDisplayTotal classifies each usage shape, failing closed on a half-known split", () => { + const cases = [ + [ + "a reported total is exact", + { inputTokens: "800", outputTokens: "200", totalTokens: "1100" }, + { kind: "exact", value: 1100n, partial: false }, + ], + [ + "an exact total flagged incomplete carries partial", + { totalTokens: ["900", true] }, + { kind: "exact", value: 900n, partial: true }, + ], + [ + "a null total with both i/o known is approximate", + { inputTokens: "800", outputTokens: "200" }, + { kind: "approximate", value: 1000n, partial: false }, + ], + [ + "an incomplete i/o field makes the approximation partial", + { inputTokens: ["800", true], outputTokens: "200" }, + { kind: "approximate", value: 1000n, partial: true }, + ], + // Fail-closed: half of a split is never enough to publish a total. + [ + "input alone is unknown", + { inputTokens: "500" }, + { kind: "unknown", value: null, partial: false }, + ], + [ + "output alone is unknown", + { outputTokens: "300" }, + { kind: "unknown", value: null, partial: false }, + ], + [ + "no reported field at all is unknown", + {}, + { kind: "unknown", value: null, partial: false }, + ], + ]; + + for (const [label, fields, expected] of cases) { + const usage = reportedUsage( + Object.fromEntries( + Object.entries(fields).map(([name, field]) => { + const [value, incomplete = false] = Array.isArray(field) + ? field + : [field]; + return [name, usageField({ value, incomplete })]; + }), + ), + ); + const dt = deriveDisplayTotal(usage); + assert.deepEqual( + { kind: dt.kind, value: dt.value, partial: dt.partial }, + expected, + label, + ); + } +}); + +// ── sortAgentsByDisplayTotal / sortModelsByDisplayTotal ───────────────────── + +/** An agent whose total is null but whose i/o sum is known — approximate tier. */ +function approxAgent(pubkey, input, output) { + return agentUsage(pubkey, null, { + usage: reportedUsage({ + inputTokens: usageField({ value: input }), + outputTokens: usageField({ value: output }), + }), + }); +} + +test("sortAgentsByDisplayTotal ranks by tier first, then value descending, then pubkey", () => { + const cases = [ + [ + "known exact totals sort descending", + [ + agentUsage("a1", "100"), + agentUsage("a2", "300"), + agentUsage("a3", "200"), + ], + ["a2", "a3", "a1"], + ], + [ + // exact(50) < approximate(18000) numerically, but the exact tier wins. + "an exact tier outranks a larger approximate total", + [approxAgent("approx", "9000", "9000"), agentUsage("exact", "50")], + ["exact", "approx"], + ], + [ + "an approximate tier outranks an unknown total", + [agentUsage("unknown", null), approxAgent("approx", "100", "50")], + ["approx", "unknown"], + ], + [ + "a mixed population lands in exact → approximate → unknown order", + [ + agentUsage("u1", null), + agentUsage("e1", "100"), + approxAgent("a1", "400", "100"), + agentUsage("u2", null), + agentUsage("e2", "300"), + approxAgent("a2", "150", "50"), + ], + ["e2", "e1", "a1", "a2", "u1", "u2"], + ], + [ + "equal totals and unknown totals alike tiebreak by pubkey", + [agentUsage("b", "100"), agentUsage("a", "100"), agentUsage("c", null)], + ["a", "b", "c"], + ], + ]; + + for (const [label, agents, expected] of cases) { + assert.deepEqual( + sortAgentsByDisplayTotal(agents).map((a) => a.agentPubkey), + expected, + label, + ); + } +}); + +test("sortModelsByDisplayTotal ranks by tier, then tiebreaks harness before model with nulls last", () => { + const equalTotals = [ + modelUsage(null, "100"), + modelUsage("gpt-4", "100"), + modelUsage("claude", "100"), + ]; + assert.deepEqual( + sortModelsByDisplayTotal(equalTotals).map((m) => m.model), + ["claude", "gpt-4", null], + "a null model ('Unknown model') sorts last among ties", + ); + + const sameModelManyHarnesses = [ + modelUsage("m", "100", { harness: "z-harness" }), + modelUsage("m", "100", { harness: "a-harness" }), + modelUsage("m", "100", { harness: null }), + ]; + assert.deepEqual( + sortModelsByDisplayTotal(sameModelManyHarnesses).map((m) => m.harness), + ["a-harness", "z-harness", null], + "the same model via several harnesses stays distinct, in harness order", + ); + + const mixedTiers = [ + modelUsage("big-approx", null, { + usage: reportedUsage({ + inputTokens: usageField({ value: "9999" }), + outputTokens: usageField({ value: "9999" }), + }), + }), + modelUsage("small-model", "10"), + ]; + assert.deepEqual( + sortModelsByDisplayTotal(mixedTiers).map((m) => m.model), + ["small-model", "big-approx"], + "the exact tier outranks a larger approximate total", + ); +}); + +// ── isPartialField / isUnknownField ────────────────────────────────────────── + +test("isPartialField and isUnknownField classify usage fields correctly", () => { + assert.equal( + isPartialField(usageField({ value: "10", incomplete: true })), + true, + ); + assert.equal( + isPartialField(usageField({ value: "10", incomplete: false })), + false, + ); + assert.equal( + isPartialField(usageField({ value: null, incomplete: true })), + false, + ); + assert.equal(isUnknownField(usageField({ value: null })), true); + assert.equal(isUnknownField(usageField({ value: "0" })), false); +}); + +// ── formatModelCacheBreakdown / hasKnownCacheData ──────────────────────────── + +test("hasKnownCacheData is true iff any cache subset carries a known value", () => { + assert.equal(hasKnownCacheData(reportedUsage()), false, "all absent → false"); + for (const field of [ + "cacheReadTokens", + "cacheWriteTokens", + "freshInputTokens", + ]) { + assert.equal( + hasKnownCacheData(reportedUsage({ [field]: usageField({ value: "0" }) })), + true, + `a known ${field} (even zero) → true`, + ); + } + // A field that is incomplete but has no value is still unknown, not known. + assert.equal( + hasKnownCacheData( + reportedUsage({ + cacheReadTokens: usageField({ value: null, incomplete: true }), + }), + ), + false, + "incomplete with null value is unknown, not known", + ); +}); + +test("formatModelCacheBreakdown omits absent subsets, never renders them as zero, and marks a known lower bound Partial", () => { + const build = (overrides) => + modelUsage("m", null, { usage: reportedUsage(overrides) }); + + // No cache data at all → null, so the caller omits the line entirely. + assert.equal(formatModelCacheBreakdown(build({})), null); + + // Each known subset appears compact-formatted; absent ones are omitted + // rather than shown as "0". + assert.equal( + formatModelCacheBreakdown( + build({ + cacheReadTokens: usageField({ value: "1500" }), + cacheWriteTokens: usageField({ value: "300" }), + freshInputTokens: usageField({ value: "1200000" }), + }), + ), + "Cache read 1.5K · Cache write 300 · Fresh 1.2M", + ); + + // Only cache-read known — the other two are unknown and omitted, not zero. + assert.equal( + formatModelCacheBreakdown( + build({ cacheReadTokens: usageField({ value: "800" }) }), + ), + "Cache read 800", + ); + + // An incomplete known field appends a single trailing Partial marker. + assert.equal( + formatModelCacheBreakdown( + build({ + cacheReadTokens: usageField({ value: "800", incomplete: true }), + cacheWriteTokens: usageField({ value: "200" }), + }), + ), + "Cache read 800 · Cache write 200 · Partial", + ); + + // An incomplete field with NO value carries no known lower bound: it is + // omitted and does not trigger Partial on its own. + assert.equal( + formatModelCacheBreakdown( + build({ + cacheReadTokens: usageField({ value: "800" }), + cacheWriteTokens: usageField({ value: null, incomplete: true }), + }), + ), + "Cache read 800", + ); +}); + +// ── sumKnownBucketTotals ────────────────────────────────────────────────────── + +function bucket(overrides = {}) { + return { + start: 1_700_000_000, + end: 1_700_086_400, + usage: reportedUsage(), + reportCount: 0, + hasUnknownUsage: false, + ...overrides, + }; +} + +/** A report-bearing bucket whose total is exactly `value`. */ +function exactBucket(value, incomplete = false) { + return bucket({ + usage: reportedUsage({ totalTokens: usageField({ value, incomplete }) }), + reportCount: 1, + }); +} + +/** A report-bearing bucket with no total but a known i/o split. */ +function approxBucket(input, output, incomplete = false) { + return bucket({ + usage: reportedUsage({ + inputTokens: usageField({ value: input, incomplete }), + outputTokens: usageField({ value: output }), + }), + reportCount: 1, + }); +} + +/** A report-bearing bucket with nothing countable at all. */ +function unknownBucket() { + return bucket({ + usage: reportedUsage(), + reportCount: 1, + hasUnknownUsage: true, + }); +} + +test("sumKnownBucketTotals aggregates buckets without erasing or fabricating a subtotal", () => { + const cases = [ + [ + "a window of empty buckets is unknown, not zero", + [bucket({ reportCount: 0 }), bucket({ reportCount: 0 })], + { kind: "unknown", value: null, partial: false }, + ], + [ + "fully-known totals sum exactly", + [exactBucket("100"), exactBucket("200")], + { kind: "exact", value: 300n, partial: false }, + ], + [ + "an incomplete (known lower-bound) total marks the sum partial", + [exactBucket("100", true), exactBucket("200")], + { kind: "exact", value: 300n, partial: true }, + ], + [ + "an unknown sibling preserves the known exact subtotal as partial", + [exactBucket("100"), unknownBucket()], + { kind: "exact", value: 100n, partial: true }, + ], + [ + "all-null totals with known i/o sum to an approximation", + [approxBucket("800", "200"), approxBucket("400", "100")], + { kind: "approximate", value: 1500n, partial: false }, + ], + [ + "an incomplete i/o field marks the approximation partial", + [approxBucket("800", "200", true), approxBucket("400", "100")], + { kind: "approximate", value: 1500n, partial: true }, + ], + [ + "mixed exact and approximate buckets aggregate as approximate", + [exactBucket("1000"), approxBucket("300", "200")], + { kind: "approximate", value: 1500n, partial: false }, + ], + [ + "an unknown sibling preserves the known approximate subtotal as partial", + [approxBucket("400", "100"), unknownBucket()], + { kind: "approximate", value: 500n, partial: true }, + ], + [ + "a lone report-bearing bucket with no countable field is unknown", + [unknownBucket()], + { kind: "unknown", value: null, partial: false }, + ], + [ + "a window where no report-bearing bucket has a display value is unknown", + [unknownBucket(), unknownBucket()], + { kind: "unknown", value: null, partial: false }, + ], + ]; + + for (const [label, buckets, expected] of cases) { + const result = sumKnownBucketTotals(buckets); + assert.deepEqual( + { kind: result.kind, value: result.value, partial: result.partial }, + expected, + label, + ); + } +}); + +// ── deriveUsageIngressTrailing ──────────────────────────────────────────────── + +function baseSeries(overrides = {}) { + return { + collectionEnabled: true, + buckets: [], + agents: [], + coverage: { + firstArchivedAt: null, + firstReportedAt: null, + hasUnknownUsage: false, + invalidReportCount: 0, + lastArchivedAt: null, + lastReportedAt: null, + reportCount: 0, + }, + hasArchivedEvidence: null, + ...overrides, + }; +} + +test("deriveUsageIngressTrailing summarizes the series, marking a known lower bound Partial", () => { + const io = (input, output, incomplete = false) => + reportedUsage({ + inputTokens: usageField({ value: input, incomplete }), + outputTokens: usageField({ value: output }), + }); + + const cases = [ + ["collection disabled", { collectionEnabled: false }, "Collection off"], + ["collection on with no agents", { agents: [] }, "No recent data"], + [ + "a known non-partial total", + { agents: [agentUsage("a", "1500")] }, + "1.5K", + ], + [ + "a total that is only a known lower bound", + { + agents: [ + agentUsage("a", null, { + usage: reportedUsage({ + totalTokens: usageField({ value: "1500", incomplete: true }), + }), + }), + ], + }, + "1.5K · Partial", + ], + [ + "i/o known but no total", + { agents: [agentUsage("a", null, { usage: io("800", "200") })] }, + "Input/output reported", + ], + [ + "i/o known but one field incomplete", + { agents: [agentUsage("a", null, { usage: io("800", "200", true) })] }, + "Input/output reported · Partial", + ], + // Fail-closed: an agent present with nothing countable is not "0". + [ + "every usage field unknown", + { agents: [agentUsage("a", null)] }, + "No recent data", + ], + ]; + + for (const [label, overrides, expected] of cases) { + assert.equal( + deriveUsageIngressTrailing(baseSeries(overrides)), + expected, + label, + ); + } +}); + +// ── Custom-range parsing, validation, and boundary construction ────────────── + +test("parseLocalDate resolves a YYYY-MM-DD string to local midnight, not UTC midnight", () => { + withTz("America/New_York", () => { + const parsed = parseLocalDate("2026-03-15"); + assert.notEqual(parsed, null); + // `new Date("2026-03-15")` is UTC midnight = Mar 14 20:00 in New York. + assert.equal(parsed.getFullYear(), 2026); + assert.equal(parsed.getMonth(), 2); + assert.equal(parsed.getDate(), 15); + assert.equal(parsed.getHours(), 0); + assert.notEqual(parsed.getTime(), new Date("2026-03-15").getTime()); + }); +}); + +test("parseLocalDate rejects malformed input and dates that do not exist", () => { + for (const value of [ + "", + "x", + "2026-3-15", + "15/03/2026", + "2026-03-15T00:00", + // `new Date(2026, 1, 30)` silently normalizes to Mar 2 — querying the + // wrong civil day. The guard must reject these outright. + "2026-02-30", + "2026-13-01", + "2026-00-10", + "2026-02-29", // not a leap year + ]) { + assert.equal(parseLocalDate(value), null, `expected null for ${value}`); + } + assert.notEqual( + parseLocalDate("2024-02-29"), + null, + "a leap day in a leap year is valid", + ); +}); + +test("formatLocalDate round-trips through parseLocalDate", () => { + withTz("America/New_York", () => { + for (const value of ["2026-01-01", "2026-03-08", "2026-12-31"]) { + assert.equal(formatLocalDate(parseLocalDate(value)), value); + } + }); +}); + +test("countRangeDays counts civil days and returns null for inverted or malformed ranges", () => { + assert.equal(countRangeDays("2026-05-04", "2026-05-04"), 1, "inclusive"); + withTz("America/New_York", () => { + // Mar 8 2026 is spring-forward: 3 civil days, not 3 × 24h. + assert.equal(countRangeDays("2026-03-07", "2026-03-09"), 3); + }); + assert.ok( + countRangeDays("1900-01-01", "2100-01-01") > MAX_RANGE_DAYS, + "an absurd range reports over-cap rather than walking it", + ); + assert.equal(countRangeDays("2026-05-10", "2026-05-01"), null); + assert.equal(countRangeDays("nope", "2026-05-01"), null); + assert.equal(countRangeDays("2026-05-01", "2026-02-30"), null); +}); + +test("validateCustomRange accepts the cap exactly and rejects over-cap, inverted, and malformed ranges", () => { + // 2024 is a leap year: Jan 1 – Dec 31 inclusive is exactly 366 civil days. + assert.deepEqual(validateCustomRange("2024-01-01", "2024-12-31"), { + ok: true, + days: MAX_RANGE_DAYS, + }); + const overCap = validateCustomRange("2024-01-01", "2025-01-01"); + assert.equal(overCap.ok, false); + assert.match(overCap.message, /366 days or fewer/); + const inverted = validateCustomRange("2026-05-10", "2026-05-01"); + assert.equal(inverted.ok, false); + assert.match(inverted.message, /on or before/); + for (const [start, end] of [ + ["", "2026-05-01"], + ["2026-05-01", ""], + ["2026-02-30", "2026-05-01"], + ]) { + const r = validateCustomRange(start, end); + assert.equal(r.ok, false); + assert.match(r.message, /start and an end date/); + } +}); + +test("buildCustomDayBoundaries returns days+1 strictly increasing boundaries closing the final day", () => { + withTz("America/New_York", () => { + // Three-day range: 4 boundaries. + const boundaries = buildCustomDayBoundaries("2026-05-01", "2026-05-03"); + assert.equal(boundaries.length, 4); + assertStrictlyIncreasing(boundaries); + assert.equal( + boundaries[0], + Math.floor(new Date(2026, 4, 1).getTime() / 1_000), + ); + assert.equal( + boundaries.at(-1), + Math.floor(new Date(2026, 4, 4).getTime() / 1_000), + "final boundary opens the day after the requested end date", + ); + // Single-day range: exactly 2 boundaries. + const single = buildCustomDayBoundaries("2026-05-04", "2026-05-04"); + assert.equal(single.length, 2); + assertStrictlyIncreasing(single); + }); +}); + +test("buildCustomDayBoundaries stays strictly increasing across a spring-forward DST transition", () => { + withTz("America/New_York", () => { + const boundaries = buildCustomDayBoundaries("2026-03-06", "2026-03-10"); + assert.equal(boundaries.length, 6); + assertStrictlyIncreasing(boundaries); + }); +}); + +test("buildCustomDayBoundaries emits no duplicate boundary across a skipped civil date", () => { + withTz("Pacific/Apia", () => { + // 2011-12-30 does not exist in Apia (date-line move): distinct midnights required. + const boundaries = buildCustomDayBoundaries("2011-12-28", "2011-12-31"); + assertStrictlyIncreasing(boundaries); + assert.equal(new Set(boundaries).size, boundaries.length); + }); +}); + +test("buildCustomDayBoundaries produces the maximum boundary count at the cap", () => { + withTz("America/New_York", () => { + const boundaries = buildCustomDayBoundaries("2024-01-01", "2024-12-31"); + assert.equal(boundaries.length, MAX_RANGE_DAYS + 1); + assertStrictlyIncreasing(boundaries); + }); +}); + +test("buildCustomDayBoundaries returns no boundaries for a range the picker rejects", () => { + assert.deepEqual(buildCustomDayBoundaries("2026-05-10", "2026-05-01"), []); + assert.deepEqual(buildCustomDayBoundaries("2024-01-01", "2025-01-01"), []); + assert.deepEqual(buildCustomDayBoundaries("", ""), []); +}); + +test("buildRangeBoundaries delegates to buildLocalDayBoundaries for presets and buildCustomDayBoundaries for custom", () => { + const now = new Date(2026, 5, 15, 12, 0, 0); + for (const days of [1, 7, 30]) { + assert.deepEqual( + buildRangeBoundaries({ kind: "preset", days }, now), + buildLocalDayBoundaries(days, now), + `preset ${days}d must not diverge from the shared day walk`, + ); + } + // 1-day preset: exactly 2 boundaries, today's and tomorrow's local midnight. + const oneDayBounds = buildRangeBoundaries({ kind: "preset", days: 1 }, now); + assert.equal(oneDayBounds.length, 2); + assert.equal( + oneDayBounds[0], + Math.floor(new Date(2026, 5, 15).getTime() / 1_000), + ); + assert.equal( + oneDayBounds[1], + Math.floor(new Date(2026, 5, 16).getTime() / 1_000), + ); + // Custom range delegates to buildCustomDayBoundaries. + assert.deepEqual( + buildRangeBoundaries({ + kind: "custom", + startDate: "2026-05-01", + endDate: "2026-05-03", + }), + buildCustomDayBoundaries("2026-05-01", "2026-05-03"), + ); +}); + +test("describeRange renders preset copy and custom date spans", () => { + assert.equal(describeRange({ kind: "preset", days: 1 }), "the last day"); + assert.equal(describeRange({ kind: "preset", days: 7 }), "the last 7 days"); + assert.equal(describeRange({ kind: "preset", days: 30 }), "the last 30 days"); + const custom = describeRange({ + kind: "custom", + startDate: "2026-05-01", + endDate: "2026-05-03", + }); + assert.match(custom, /2026/); + assert.match(custom, /–/); +}); diff --git a/desktop/src/features/agent-usage/lib/agentUsage.ts b/desktop/src/features/agent-usage/lib/agentUsage.ts new file mode 100644 index 00000000000..99ddd71f4af --- /dev/null +++ b/desktop/src/features/agent-usage/lib/agentUsage.ts @@ -0,0 +1,676 @@ +//! Frontend-owned local-day boundary construction, bigint-safe token +//! handling, and truthful-state derivation for the NIP-AM local agent usage +//! feature. +//! +//! Rust request validation (`agent_usage.rs::validate_request`) only bounds +//! query span and shape — per M5, the trusted frontend is the single source +//! of local-midnight civil-day construction. Every consumer of +//! `AgentUsageSeriesRequest.bucketBoundaries` must build them here. + +import type { + AgentUsage, + AgentUsageModel, + AgentUsageSeries, + AgentUsageSeriesBucket, + CostField, + UsageField, +} from "@/shared/api/tauriArchive"; + +// ── Local-day boundary construction (M5, A9) ───────────────────────────────── + +export type UsageWindowDays = number; + +/** + * A selected usage window. Preset ranges are a trailing day count ending + * today; a custom range is an explicit inclusive local-date pair chosen in + * the picker. + */ +export type UsageRange = + | { kind: "preset"; days: 1 | 7 | 30 } + | { kind: "custom"; startDate: string; endDate: string }; + +export const DEFAULT_USAGE_RANGE: UsageRange = { kind: "preset", days: 7 }; + +/** + * Largest number of daily buckets a range may cover — one leap year. Mirrors + * `MAX_BOUNDARIES = 367` (bucket count + 1) in + * `desktop/src-tauri/src/archive/agent_usage.rs`. The picker clamps to this + * so the backend's fail-closed arity check is never the UX error path. + */ +export const MAX_RANGE_DAYS = 366; + +const DISTINCT_MIDNIGHT_MAX_STEP = 3; + +/** + * The local midnight strictly before `from` (which must itself be a local + * midnight), found via `Date#setDate` day-arithmetic so ordinary DST + * transitions land on the correct calendar day. `Date#setDate` normalizes a + * *nonexistent* local date (a full civil day dropped by a date-line move, + * e.g. `Pacific/Apia`'s 2011-12-30) forward to the next real one, which can + * renormalize back to `from` itself — so this widens the step by one + * calendar day at a time until it actually lands on a distinct instant. + */ +function previousDistinctLocalMidnight(from: Date): Date { + let probe = from; + for (let step = 1; step <= DISTINCT_MIDNIGHT_MAX_STEP; step++) { + probe = new Date(from); + probe.setDate(probe.getDate() - step); + probe.setHours(0, 0, 0, 0); + if (probe.getTime() !== from.getTime()) return probe; + } + return probe; +} + +/** The local midnight strictly after `from`; see {@link previousDistinctLocalMidnight}. */ +function nextDistinctLocalMidnight(from: Date): Date { + let probe = from; + for (let step = 1; step <= DISTINCT_MIDNIGHT_MAX_STEP; step++) { + probe = new Date(from); + probe.setDate(probe.getDate() + step); + probe.setHours(0, 0, 0, 0); + if (probe.getTime() !== from.getTime()) return probe; + } + return probe; +} + +/** + * Build `days + 1` exact local-midnight Unix-second boundaries ending at the + * start of tomorrow's local day, covering the trailing `days` calendar days + * (today plus `days - 1` prior days). + * + * Walks to each boundary's *distinct* local midnight one civil day at a + * time (never independent `Date#setDate` offsets from one shared base date, + * and never `N * 86_400`), so boundaries stay correct across DST + * transitions — including 30-minute offset zones (e.g. Lord Howe Island), + * where a "day" is 23.5h or 24.5h — and across a skipped local civil date + * (e.g. `Pacific/Apia`'s 2011 date-line move), where independently offsetting + * from one base date would normalize the nonexistent date forward and emit + * a duplicate boundary. A skipped date instead produces one interval + * spanning the elapsed real time between the two surviving distinct + * midnights (which can exceed the ordinary 24h, up to the 48h band + * `validate_request`'s `MAX_INTERVAL_SECS` (A9) admits) rather than a + * duplicate. `referenceNow` is injectable for deterministic tests and the + * midnight-rollover timer (M4). + */ +export function buildLocalDayBoundaries( + days: UsageWindowDays, + referenceNow: Date = new Date(), +): number[] { + const todayMidnight = new Date(referenceNow); + todayMidnight.setHours(0, 0, 0, 0); + + const tomorrowMidnight = nextDistinctLocalMidnight(todayMidnight); + + // Oldest boundary is `days - 1` distinct local midnights before today's; + // the window covers today plus the (days - 1) preceding calendar days. + const priorMidnights: Date[] = []; + let cursor = todayMidnight; + for (let i = 0; i < days - 1; i++) { + cursor = previousDistinctLocalMidnight(cursor); + priorMidnights.push(cursor); + } + priorMidnights.reverse(); + + return [...priorMidnights, todayMidnight, tomorrowMidnight].map((d) => + Math.floor(d.getTime() / 1_000), + ); +} + +/** + * Milliseconds until the next local midnight after `referenceNow`, for the + * single-`setTimeout` rollover (M4). Recompute and reschedule each time the + * timer fires — never use `setInterval`, which drifts across DST. + */ +export function msUntilNextLocalMidnight( + referenceNow: Date = new Date(), +): number { + const nextMidnight = new Date(referenceNow); + nextMidnight.setHours(24, 0, 0, 0); + return nextMidnight.getTime() - referenceNow.getTime(); +} + +/** + * Local midnight opening the civil day named by a `YYYY-MM-DD` string. + * Parsed field-wise into the local zone — never `new Date("YYYY-MM-DD")`, + * which JS parses as *UTC* midnight and so lands on the previous civil day + * for every negative-offset zone. + * + * Returns `null` for a malformed string or a field triple that isn't a real + * calendar date (e.g. `2026-02-30`), which `Date` would silently roll forward. + */ +export function parseLocalDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (match === null) return null; + const [year, month, day] = [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + ]; + const parsed = new Date(year, month - 1, day); + parsed.setHours(0, 0, 0, 0); + // Reject a rolled-forward nonexistent date. A civil date genuinely skipped + // by a date-line move still normalizes to a different day-of-month, so it + // is rejected here too rather than silently querying the wrong day. + if ( + parsed.getFullYear() !== year || + parsed.getMonth() !== month - 1 || + parsed.getDate() !== day + ) { + return null; + } + return parsed; +} + +/** Local `YYYY-MM-DD` for a date, for round-tripping through the picker's ``. */ +export function formatLocalDate(date: Date): string { + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +/** + * Number of distinct local civil days in the inclusive `[startDate, endDate]` + * range, or `null` if either date is malformed or the range is inverted. + * Counts by walking distinct local midnights, so it agrees exactly with the + * boundary count {@link buildRangeBoundaries} produces across DST and skipped + * civil dates. Stops counting past {@link MAX_RANGE_DAYS} so an absurd range + * can't spin — callers treat an over-cap result as a validation failure. + */ +export function countRangeDays( + startDate: string, + endDate: string, +): number | null { + const start = parseLocalDate(startDate); + const end = parseLocalDate(endDate); + if (start === null || end === null) return null; + if (start.getTime() > end.getTime()) return null; + + let days = 1; + let cursor = start; + while (cursor.getTime() < end.getTime()) { + cursor = nextDistinctLocalMidnight(cursor); + days += 1; + if (days > MAX_RANGE_DAYS) return days; + } + return days; +} + +/** + * Validation result for a custom range, carrying the human-facing reason so + * the picker can surface it instead of letting a rejected request surface a + * raw Rust error string. + */ +export type RangeValidation = + | { ok: true; days: number } + | { ok: false; message: string }; + +/** Validate a custom range against the picker's contract: real dates, ordered, within one year. */ +export function validateCustomRange( + startDate: string, + endDate: string, +): RangeValidation { + const start = parseLocalDate(startDate); + const end = parseLocalDate(endDate); + if (start === null || end === null) { + return { ok: false, message: "Enter both a start and an end date." }; + } + if (start.getTime() > end.getTime()) { + return { ok: false, message: "Start date must be on or before end date." }; + } + const days = countRangeDays(startDate, endDate); + if (days === null) { + return { ok: false, message: "Enter both a start and an end date." }; + } + if (days > MAX_RANGE_DAYS) { + return { + ok: false, + message: `Pick a range of ${MAX_RANGE_DAYS} days or fewer.`, + }; + } + return { ok: true, days }; +} + +/** + * Local-midnight boundaries covering the inclusive civil-day range + * `[startDate, endDate]` — `days + 1` entries, ending at the midnight that + * closes `endDate`. Walks distinct local midnights exactly like + * {@link buildLocalDayBoundaries}, so DST transitions, 30-minute-offset + * zones, and skipped civil dates behave identically. + * + * Returns `[]` for a range that fails {@link validateCustomRange}, so a + * malformed or over-cap range yields no query rather than a rejected one. + */ +export function buildCustomDayBoundaries( + startDate: string, + endDate: string, +): number[] { + const validation = validateCustomRange(startDate, endDate); + if (!validation.ok) return []; + + const start = parseLocalDate(startDate); + const end = parseLocalDate(endDate); + if (start === null || end === null) return []; + + const midnights: Date[] = [start]; + let cursor = start; + while (cursor.getTime() < end.getTime()) { + cursor = nextDistinctLocalMidnight(cursor); + midnights.push(cursor); + } + // Close the final civil day so the last bucket is end-exclusive. + midnights.push(nextDistinctLocalMidnight(cursor)); + + return midnights.map((d) => Math.floor(d.getTime() / 1_000)); +} + +/** + * Boundaries for any {@link UsageRange}. The single entry point the query + * layer uses, so presets and custom ranges cannot diverge in how civil days + * are constructed. + */ +export function buildRangeBoundaries( + range: UsageRange, + referenceNow: Date = new Date(), +): number[] { + return range.kind === "preset" + ? buildLocalDayBoundaries(range.days, referenceNow) + : buildCustomDayBoundaries(range.startDate, range.endDate); +} + +/** + * Human-facing label for the window, used in empty-state and a11y copy. + * Phrased to read after "for" — "for the last 7 days", "for Jan 1, 2026 – + * Feb 1, 2026" — so both range kinds fit the same sentence. + */ +export function describeRange(range: UsageRange): string { + if (range.kind === "preset") { + return range.days === 1 ? "the last day" : `the last ${range.days} days`; + } + return `${formatRangeEndpoint(range.startDate)} – ${formatRangeEndpoint(range.endDate)}`; +} + +/** Short, year-bearing display for a custom endpoint; falls back to the raw string if unparseable. */ +function formatRangeEndpoint(value: string): string { + const parsed = parseLocalDate(value); + if (parsed === null) return value; + return parsed.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +// ── Bigint-safe token parsing/formatting ───────────────────────────────────── + +/** + * Parse a decimal token-count string to `bigint`, fail-closed. The wire + * sends token counters as decimal strings specifically so the full valid + * `u64` range survives the Tauri boundary — never round-trip through + * `Number(...)`, which loses precision above 2^53. + * + * Returns `null` for a null/missing value or a string that isn't a plain + * non-negative decimal integer (defensive: malformed wire data becomes + * "unknown", not a thrown parse error that would crash the panel). + */ +export function parseTokenCount(value: string | null): bigint | null { + if (value === null) return null; + if (!/^\d+$/.test(value)) return null; + try { + return BigInt(value); + } catch { + return null; + } +} + +/** Compact display, e.g. `1234` -> "1.2K", `1_000_000` -> "1M". Never lossy for exact copy — use `formatTokenCountExact` for that. */ +export function formatTokenCountCompact(value: bigint): string { + const abs = value < 0n ? -value : value; + const units: Array<[bigint, string]> = [ + [1_000_000_000n, "B"], + [1_000_000n, "M"], + [1_000n, "K"], + ]; + for (const [threshold, suffix] of units) { + if (abs >= threshold) { + // One decimal place, computed in bigint math to stay exact until the + // final float division (bounded to a single small ratio, not the + // original magnitude, so no precision loss that matters visually). + const scaled = Number((value * 10n) / threshold) / 10; + return `${scaled}${suffix}`; + } + } + return value.toString(); +} + +/** Exact grouped display, e.g. `1234567` -> "1,234,567". Safe for arbitrary `bigint` magnitude. */ +export function formatTokenCountExact(value: bigint): string { + return value.toLocaleString("en-US"); +} + +/** Exact USD display, e.g. `1.5` -> "$1.50". `null` callers should render "Estimated" copy elsewhere, never "$0.00". */ +export function formatEstimatedCostUsd(value: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(value); +} + +/** Short coverage-date display, e.g. `1737849600` -> "Jan 25". `null` renders "unknown". */ +export function formatCoverageDate(unixSeconds: number | null): string { + if (unixSeconds === null) return "unknown"; + return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +/** + * Bigint-safe ratio in `[0, 1]` for a relative bar, e.g. `part` tokens against + * `whole` tokens. Never converts the full magnitude through `Number(...)`; + * only the final small ratio is a float. Returns `0` when `whole` is zero or + * negative (guards a divide-by-zero, not a real data case). + */ +export function bigintRatio(part: bigint, whole: bigint): number { + if (whole <= 0n) return 0; + const clampedPart = part < 0n ? 0n : part > whole ? whole : part; + // Scale into an integer permille before the single float division so the + // division only ever operates on bounded small integers. + const permille = (clampedPart * 1000n) / whole; + return Number(permille) / 1000; +} + +// ── Display total derivation (A2 presentation layer) ───────────────────────── + +/** + * A provenance-bearing display total for the usage UI. Only one of three + * states is ever active: + * + * - `exact`: `totalTokens.value` is present and parsed. `partial` mirrors the + * wire field's `incomplete` flag. + * - `approximate`: `totalTokens.value` is absent and BOTH `inputTokens` and + * `outputTokens` are known; `value` is their bigint-safe sum. `partial` is + * `inputTokens.incomplete || outputTokens.incomplete`. Callers MUST render + * `≈` to distinguish this from a provider total. A missing category is + * unknown, not zero — one-sided i/o yields `unknown`, not `approximate`. + * - `unknown`: no token counts are available at all; `value` is `null`. + * + * This is a *display* value only — it is NEVER written to the wire or stored. + * NIP-AM's "MUST NOT derive total = input + output" governs published/stored + * data; this label lives entirely in the presentation layer. + */ +export type DisplayTotal = + | { kind: "exact"; value: bigint; partial: boolean } + | { kind: "approximate"; value: bigint; partial: boolean } + | { kind: "unknown"; value: null; partial: false }; + +export function deriveDisplayTotal(usage: { + inputTokens: UsageField; + outputTokens: UsageField; + totalTokens: UsageField; +}): DisplayTotal { + const exact = parseTokenCount(usage.totalTokens.value); + if (exact !== null) { + return { + kind: "exact", + value: exact, + partial: isPartialField(usage.totalTokens), + }; + } + const input = parseTokenCount(usage.inputTokens.value); + const output = parseTokenCount(usage.outputTokens.value); + if (input !== null && output !== null) { + return { + kind: "approximate", + value: input + output, + partial: + isPartialField(usage.inputTokens) || isPartialField(usage.outputTokens), + }; + } + return { kind: "unknown", value: null, partial: false }; +} + +// ── Ranking (A2: rank by display total — exact > approximate > unknown) ────── + +type DisplayTierKey = 0 | 1 | 2; // 0 = exact, 1 = approximate, 2 = unknown + +type RankedWithDisplay = { + item: T; + displayTotal: DisplayTotal; + tierKey: DisplayTierKey; +}; + +function tierOf(dt: DisplayTotal): DisplayTierKey { + if (dt.kind === "exact") return 0; + if (dt.kind === "approximate") return 1; + return 2; +} + +/** + * Sort items by their display total: + * 1. Exact totals rank first, descending by value. + * 2. Approximate totals (≈ in+out) rank next, descending by value. + * 3. Unknown totals rank last, unordered beyond the tiebreak. + * Within the same tier and value, `tiebreak` resolves the order. + */ +function rankByDisplayTotal( + items: readonly T[], + getUsage: (item: T) => { + inputTokens: UsageField; + outputTokens: UsageField; + totalTokens: UsageField; + }, + tiebreak: (a: T, b: T) => number, +): T[] { + const withDisplay: RankedWithDisplay[] = items.map((item) => { + const dt = deriveDisplayTotal(getUsage(item)); + return { item, displayTotal: dt, tierKey: tierOf(dt) }; + }); + + return withDisplay + .sort((a, b) => { + if (a.tierKey !== b.tierKey) return a.tierKey - b.tierKey; + // Same tier — for exact/approximate, sort descending by value. + if (a.displayTotal.value !== null && b.displayTotal.value !== null) { + if (a.displayTotal.value !== b.displayTotal.value) { + return a.displayTotal.value > b.displayTotal.value ? -1 : 1; + } + } + return tiebreak(a.item, b.item); + }) + .map((ranked) => ranked.item); +} + +/** Agents sort by display total (exact → approximate → unknown), descending by value within tier, then normalized pubkey. */ +export function sortAgentsByDisplayTotal( + agents: readonly AgentUsage[], +): AgentUsage[] { + return rankByDisplayTotal( + agents, + (agent) => agent.usage, + (a, b) => a.agentPubkey.localeCompare(b.agentPubkey), + ); +} + +/** Model rows use the same display-total ranking, tiebroken by harness name + * (null harness sorts last), then by model name (null model sorts last). + * Ordinal (`<`/`>`) comparators are used so ordering is locale-independent + * and matches the Rust backend's `String::cmp` byte order. */ +export function sortModelsByDisplayTotal( + models: readonly AgentUsageModel[], +): AgentUsageModel[] { + return rankByDisplayTotal( + models, + (model) => model.usage, + (a, b) => { + const harnessCmp = + a.harness === b.harness + ? 0 + : a.harness === null + ? 1 + : b.harness === null + ? -1 + : a.harness < b.harness + ? -1 + : 1; + if (harnessCmp !== 0) return harnessCmp; + if (a.model === b.model) return 0; + if (a.model === null) return 1; + if (b.model === null) return -1; + return a.model < b.model ? -1 : 1; + }, + ); +} + +// ── Coverage / partial-state copy helpers ──────────────────────────────────── + +/** A field is a "Partial" lower bound when it has a known value that is flagged incomplete. Distinct from fully unknown (`value === null`), which renders as an omitted/unknown state, never zero. */ +export function isPartialField(field: UsageField | CostField): boolean { + return field.value !== null && field.incomplete; +} + +/** True when a field has no known value at all — omit from totals/bars, never render as zero. */ +export function isUnknownField(field: UsageField | CostField): boolean { + return field.value === null; +} + +/** + * Compact per-model input breakdown for the focused view: the cache-read, + * cache-write, and fresh-input subsets of input, each shown only when known + * (`value !== null`) and never as zero. Returns `null` when no subset is + * known at all, so the caller omits the line entirely rather than printing + * three unknowns. A trailing ` · Partial` marks that at least one shown field + * is a known-but-incomplete lower bound — matching the row's Partial badge and + * {@link deriveUsageIngressTrailing}'s text convention. Absent (`null`) fields + * are simply omitted, never marked Partial (they carry no known lower bound). + */ +export function formatModelCacheBreakdown( + model: AgentUsageModel, +): string | null { + const { cacheReadTokens, cacheWriteTokens, freshInputTokens } = model.usage; + const parts: string[] = []; + const push = (label: string, field: UsageField) => { + const parsed = parseTokenCount(field.value); + if (parsed !== null) { + parts.push(`${label} ${formatTokenCountCompact(parsed)}`); + } + }; + push("Cache read", cacheReadTokens); + push("Cache write", cacheWriteTokens); + push("Fresh", freshInputTokens); + if (parts.length === 0) return null; + const partial = + isPartialField(cacheReadTokens) || + isPartialField(cacheWriteTokens) || + isPartialField(freshInputTokens); + return partial ? `${parts.join(" · ")} · Partial` : parts.join(" · "); +} + +/** + * True when a usage scope has any known cache-read, cache-write, or + * fresh-input value — the gate for showing the focused view's input-breakdown + * subsection. When every cache subset is unknown (old-harness data that never + * reported cache tokens), the subsection is omitted rather than rendering a + * row of "—", which keeps absence honest without visual noise. + */ +export function hasKnownCacheData(usage: { + cacheReadTokens: UsageField; + cacheWriteTokens: UsageField; + freshInputTokens: UsageField; +}): boolean { + return ( + !isUnknownField(usage.cacheReadTokens) || + !isUnknownField(usage.cacheWriteTokens) || + !isUnknownField(usage.freshInputTokens) + ); +} + +/** + * Truthful trailing summary for the profile Info-tab Usage ingress row + * (plan:328): the viewer's own agent's 7-day known total, `Partial` when + * incomplete, `Input/output reported` when only those fields are known, + * or `No recent data` when nothing in the window is known. Never renders + * the placeholder `"View"` the ingress row used to show unconditionally. + */ +export function deriveUsageIngressTrailing(series: AgentUsageSeries): string { + if (!series.collectionEnabled) return "Collection off"; + + const agent = series.agents[0]; + if (agent === undefined) return "No recent data"; + + const { inputTokens, outputTokens, totalTokens } = agent.usage; + const knownTotal = parseTokenCount(totalTokens.value); + if (knownTotal !== null) { + const compact = formatTokenCountCompact(knownTotal); + return isPartialField(totalTokens) ? `${compact} · Partial` : compact; + } + if ( + parseTokenCount(inputTokens.value) !== null || + parseTokenCount(outputTokens.value) !== null + ) { + const ioPartial = + isPartialField(inputTokens) || isPartialField(outputTokens); + return ioPartial + ? "Input/output reported · Partial" + : "Input/output reported"; + } + return "No recent data"; +} + +/** + * Aggregate the per-bucket display totals across a daily series into a single + * provenance-bearing `DisplayTotal` for the overview/focused-view header. + * + * Aggregation rules: + * - `exact`: every report-bearing bucket contributed an exact display total. + * - `approximate`: at least one bucket contributed an approximate value; + * `partial` is the union of contributing buckets' `DisplayTotal.partial`. + * Unknown-bucket peers set `partial = true` but do NOT erase the known sum — + * the result surfaces a labeled lower bound rather than hiding measured data. + * - `unknown`: NO report-bearing bucket has any display value at all. + * - Empty window (no report-bearing buckets): `{ kind: "unknown", value: null, partial: false }`. + * + * `partial` reflects i/o and total completeness of contributing buckets. + * An approximate aggregate with complete i/o and no exact totals carries + * `partial: false` — total absence alone does NOT trigger partial. + * + * The returned value is a *display* value only — never stored or wired. + */ +export function sumKnownBucketTotals( + buckets: readonly AgentUsageSeriesBucket[], +): DisplayTotal { + let sumValue = 0n; + let sawAny = false; // any report-bearing bucket processed + let anyApprox = false; // at least one approximate bucket contributed a value + let anyWithValue = false; // at least one bucket contributed a numeric value + let partial = false; + + for (const bucket of buckets) { + if (bucket.reportCount === 0) continue; + sawAny = true; + const dt = deriveDisplayTotal(bucket.usage); + if (dt.kind === "exact" || dt.kind === "approximate") { + sumValue += dt.value; + anyWithValue = true; + if (dt.partial) partial = true; + if (dt.kind === "approximate") anyApprox = true; + } else { + // Report-bearing bucket with no display value — sets partial but does NOT + // erase the sum already accumulated from sibling buckets. + partial = true; + } + } + + if (!sawAny) { + // Truly empty window — no report-bearing buckets at all. + return { kind: "unknown", value: null, partial: false }; + } + if (!anyWithValue) { + // Report-bearing buckets exist but none had any display value. + return { kind: "unknown", value: null, partial: false }; + } + if (anyApprox) { + return { kind: "approximate", value: sumValue, partial }; + } + return { kind: "exact", value: sumValue, partial }; +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx b/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx new file mode 100644 index 00000000000..4c659684628 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx @@ -0,0 +1,316 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import type { AgentUsageSeriesBucket } from "@/shared/api/tauriArchive"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { + bigintRatio, + deriveDisplayTotal, + formatTokenCountCompact, + formatTokenCountExact, + isPartialField, + parseTokenCount, +} from "../lib/agentUsage"; + +const BAR_TRACK_HEIGHT_PX = 56; +const UNKNOWN_BASELINE_HEIGHT_PX = 10; +const KNOWN_MIN_HEIGHT_PX = 3; + +/** + * Above this bucket count the per-bar value labels and every date tick stop + * fitting, so values move into the tooltip only and date ticks thin out to + * first/last plus regular intervals. 31 buckets (the 30d preset) still fits + * date ticks at an interval; a custom year-long range does not. + */ +const DENSE_BUCKET_THRESHOLD = 14; + +// A hatched, non-zero baseline for "activity happened but the total +// couldn't be counted" — deliberately never a zero-height bar, so unknown +// usage is never visually indistinguishable from a day with no activity +// (plan:306/329: "does not encode unknown as zero"). +const UNKNOWN_BAR_STYLE: React.CSSProperties = { + backgroundImage: + "repeating-linear-gradient(45deg, var(--muted-foreground) 0, var(--muted-foreground) 1px, transparent 1px, transparent 5px)", + backgroundColor: "transparent", + height: UNKNOWN_BASELINE_HEIGHT_PX, + opacity: 0.35, +}; + +function dateLabelOf(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +/** + * One bar's derived render state, computed from backend truth rather than + * the field's `value` alone: `reportCount === 0` is a genuine zero-activity + * day (the field is `null` because nothing happened), which is a different + * state from `hasUnknownUsage` (activity happened but the total could not + * be fully counted) even though both leave `usage.totalTokens.value` null. + * + * When no genuine total is available but i/o counts are known, the bar falls + * back to an approx i/o sum (`kind: "approx"`) rather than the hatched + * unknown baseline — the bar height becomes meaningful and is labeled `≈`. + */ +function deriveBarState(bucket: AgentUsageSeriesBucket) { + const total = bucket.usage.totalTokens; + const known = parseTokenCount(total.value); + const dateLabel = dateLabelOf(bucket.start); + + if (bucket.reportCount === 0) { + return { + accessibleLabel: `${dateLabel} · no usage reported`, + dateLabel, + kind: "empty" as const, + knownTokens: 0n, + }; + } + if (known !== null) { + const partial = isPartialField(total); + return { + accessibleLabel: `${dateLabel} · ${formatTokenCountCompact(known)} reported tokens${ + partial ? " (partial)" : "" + }`, + dateLabel, + kind: partial ? ("partial" as const) : ("known" as const), + knownTokens: known, + }; + } + // Genuine total unknown — derive the display total for the bar. + const dt = deriveDisplayTotal(bucket.usage); + if (dt.kind === "approximate") { + return { + accessibleLabel: `${dateLabel} · ≈ ${formatTokenCountCompact(dt.value)} tokens${ + dt.partial ? " (partial)" : "" + }`, + dateLabel, + kind: dt.partial ? ("approx-partial" as const) : ("approx" as const), + knownTokens: dt.value, + }; + } + return { + accessibleLabel: `${dateLabel} · unknown usage`, + dateLabel, + kind: "unknown" as const, + knownTokens: null, + }; +} + +/** + * The compact value rendered directly on the bar. Carries the same + * provenance markers the header uses: `≈` for an in+out approximation, `≥` + * for a known-but-incomplete lower bound, a trailing `*` when partial, and + * `—` when nothing is countable. + */ +function barValueText( + kind: ReturnType["kind"], + knownTokens: bigint | null, +): string { + if (kind === "unknown") return "—"; + const compact = formatTokenCountCompact(knownTokens ?? 0n); + switch (kind) { + case "partial": + return `≥${compact}`; + case "approx-partial": + return `≈${compact}*`; + case "approx": + return `≈${compact}`; + default: + return compact; + } +} + +/** + * Exact per-field breakdown for the hover tooltip, so total/input/output are + * legible without opening the focused view. Each field is reported + * independently: a null field renders "unknown", never zero, and the total + * keeps its exact/≈/unknown provenance rather than being derived from the + * i/o pair shown beside it. + */ +function barBreakdown(bucket: AgentUsageSeriesBucket): { + total: string; + input: string; + output: string; +} { + const dt = deriveDisplayTotal(bucket.usage); + const total = + dt.kind === "exact" + ? formatTokenCountExact(dt.value) + : dt.kind === "approximate" + ? `≈ ${formatTokenCountExact(dt.value)}` + : "unknown"; + const totalSuffix = dt.kind !== "unknown" && dt.partial ? " (partial)" : ""; + + const field = (f: { value: string | null; incomplete: boolean }): string => { + const parsed = parseTokenCount(f.value); + if (parsed === null) return "unknown"; + return `${formatTokenCountExact(parsed)}${isPartialField(f) ? " (partial)" : ""}`; + }; + + return { + total: `${total}${totalSuffix}`, + input: field(bucket.usage.inputTokens), + output: field(bucket.usage.outputTokens), + }; +} + +/** + * CSS-only daily bar chart for a usage series (plan:305-306/329). Columns are + * equal CSS-grid fractions of the container so the chart never causes + * horizontal overflow regardless of window width or bucket count (2, 8, 31, + * or a custom range up to a year). + * + * Each bar renders its token value directly on the bar, labels the **date** + * beneath it on the x-axis, and exposes a hover tooltip with the exact + * total/input/output breakdown so the split is readable without opening the + * focused view. Accessible `aria-label`s carry the same `date · value` truth + * for screen readers. A day with reported-but-uncountable usage renders a + * fixed hatched baseline, never a zero-height bar. + * + * Dense ranges (more than {@link DENSE_BUCKET_THRESHOLD} buckets) drop the + * on-bar value text, which cannot fit legibly, and thin the date ticks to + * first, last, and a regular interval. The tooltip still carries every bar's + * full breakdown, so no information is lost. + */ +export function AgentUsageDailyBars({ + buckets, +}: { + buckets: AgentUsageSeriesBucket[]; +}) { + const maxKnownTotal = React.useMemo( + () => + buckets.reduce((max, bucket) => { + const total = parseTokenCount(bucket.usage.totalTokens.value); + if (total !== null) return total > max ? total : max; + // Fall back to the display total's approximate value so bars scale + // correctly when no bucket reports a genuine total. + const dt = deriveDisplayTotal(bucket.usage); + return dt.kind === "approximate" && dt.value > max ? dt.value : max; + }, 0n), + [buckets], + ); + + if (buckets.length === 0) return null; + + const dense = buckets.length > DENSE_BUCKET_THRESHOLD; + // At most ~7 date ticks in a dense range; first and last are always shown. + const tickInterval = dense ? Math.ceil(buckets.length / 7) : 1; + + return ( +
+ {buckets.map((bucket, index) => ( + + ))} +
+ ); +} + +function DailyBar({ + bucket, + maxKnownTotal, + showDateLabel, + showValueLabel, +}: { + bucket: AgentUsageSeriesBucket; + maxKnownTotal: bigint; + showDateLabel: boolean; + showValueLabel: boolean; +}) { + const { accessibleLabel, dateLabel, kind, knownTokens } = + deriveBarState(bucket); + const breakdown = barBreakdown(bucket); + + const knownHeightPx = + knownTokens !== null && maxKnownTotal > 0n + ? Math.max( + Math.round( + bigintRatio(knownTokens, maxKnownTotal) * BAR_TRACK_HEIGHT_PX, + ), + knownTokens > 0n ? KNOWN_MIN_HEIGHT_PX : 1, + ) + : KNOWN_MIN_HEIGHT_PX; + + return ( + + +
+ {showValueLabel ? ( + + {barValueText(kind, knownTokens)} + + ) : null} +
+ {kind === "unknown" ? ( +
+ ) : ( +
+ )} +
+ {/* Non-breaking space holds the tick row's height when a dense + range hides this bar's date, so bars stay baseline-aligned. */} + + {showDateLabel ? dateLabel : "\u00A0"} + +
+ + + {dateLabel} + Total: {breakdown.total} + Input: {breakdown.input} + Output: {breakdown.output} + + + ); +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx b/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx new file mode 100644 index 00000000000..7414c9303c3 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx @@ -0,0 +1,490 @@ +import * as React from "react"; +import { RefreshCw } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import type { + AgentUsageModel, + AgentUsageSeries, +} from "@/shared/api/tauriArchive"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { useAgentUsageSeries } from "../hooks"; +import { + DEFAULT_USAGE_RANGE, + deriveDisplayTotal, + describeRange, + formatCoverageDate, + formatEstimatedCostUsd, + formatModelCacheBreakdown, + formatTokenCountCompact, + formatTokenCountExact, + hasKnownCacheData, + isPartialField, + isUnknownField, + parseTokenCount, + sortModelsByDisplayTotal, + type DisplayTotal, + type UsageRange, +} from "../lib/agentUsage"; +import { AgentUsageDailyBars } from "./AgentUsageDailyBars"; +import { AgentUsageRangeTabs } from "./AgentUsageRangeTabs"; + +/** + * Per-agent Usage focused subview, rendered from the profile panel when + * `view === 'usage'` (M4/A9/A13, frozen Rev 3 plan). Owns its own window + * selector and author-filtered query — independent of the Agents overview. + * + * A13 fail-closed: eligibility is ownership (`canViewUsage`) OR archived + * evidence for a historical/deleted agent (`hasArchivedEvidence === true`). + * A hand-authored `?profileView=usage` URL with neither falls back to the + * summary view via `onIneligible` — but only once the query resolves, so a + * still-loading owner-eligible or evidence-eligible agent is never bounced. + */ +export function AgentUsageFocusedView({ + agentPubkey, + canViewUsage, + onIneligible, +}: { + agentPubkey: string; + canViewUsage: boolean; + onIneligible: () => void; +}) { + const [range, setRange] = React.useState(DEFAULT_USAGE_RANGE); + const query = useAgentUsageSeries({ agentPubkey, range }); + const { onOpenSettings } = useAppShell(); + + React.useEffect(() => { + if (canViewUsage || !query.data) return; + if (query.data.hasArchivedEvidence !== true) onIneligible(); + }, [canViewUsage, onIneligible, query.data]); + + return ( +
+ + + {query.isLoading ? ( + + ) : query.isError ? ( + + + Couldn't load usage data. + + + + ) : query.data ? ( + + ) : null} +
+ ); +} + +function AgentUsageFocusedSkeleton() { + return ( + + + + + + ); +} + +function AgentUsageFocusedContent({ + onOpenSettings, + range, + series, +}: { + onOpenSettings: ((section: "local-archive") => void) | null; + range: UsageRange; + series: AgentUsageSeries; +}) { + const agent = series.agents[0]; + const collectionOff = !series.collectionEnabled; + const hasRetainedData = series.coverage.reportCount > 0; + // Invalid-only: in-window invalid rows exist but none were bucketed (A5/A11). + // Distinct from outside-window history — we have evidence in this window, + // it just couldn't be counted. Must not be mislabeled as outside-window. + const hasInvalidOnlyInWindow = + agent === undefined && + series.collectionEnabled && + series.coverage.invalidReportCount > 0; + const hasEvidenceOutsideWindow = + agent === undefined && + !hasInvalidOnlyInWindow && + series.hasArchivedEvidence === true; + + if ( + !collectionOff && + agent === undefined && + !hasEvidenceOutsideWindow && + !hasInvalidOnlyInWindow + ) { + return ( +

+ No locally archived usage in {describeRange(range)}. Usage appears after + this agent completes a usage-reporting turn. +

+ ); + } + + return ( +
+ {collectionOff ? ( + + + + {hasRetainedData + ? `Collection off · data through ${formatCoverageDate( + series.coverage.lastArchivedAt, + )}` + : "Local usage collection is off."} + + + + + ) : null} + + {agent ? ( + + ) : hasEvidenceOutsideWindow ? ( +

+ No locally archived usage in {describeRange(range)}, but this agent + has reported usage previously. Try a wider window. +

+ ) : hasInvalidOnlyInWindow ? ( +

+ Usage was collected in {describeRange(range)} but could not be counted + — reports with unreadable timestamps or missing session totals are + excluded and are not assigned to any day. +

+ ) : null} +
+ ); +} + +function AgentUsageFocusedTotals({ + agent, + coverage, +}: { + agent: AgentUsageSeries["agents"][number]; + coverage: AgentUsageSeries["coverage"]; +}) { + const { estimatedCostUsd, inputTokens, outputTokens } = agent.usage; + const models = sortModelsByDisplayTotal(agent.models); + // Each caveat sentence is gated only on the condition that proves it: + // - unknown-intervals sentence: direct i/o incompleteness — true when at + // least one input or output field is known but flagged incomplete. This + // is the condition the copy claims ("input/output usage could not be + // counted"). `hasUnknownUsage` is NOT used here because it ORs total + // and cost incompleteness too, which cannot prove an i/o interval claim. + // - invalid-reports sentence: `coverage.invalidReportCount > 0` — true + // when rows were excluded from buckets due to bad timestamps or missing + // session cumulative totals. + // We do NOT trigger on totalTokens.value being null — that's the permanent + // state for all real publishers today, not a data quality problem. + const showUnknownIntervalsCaveat = + isPartialField(inputTokens) || isPartialField(outputTokens); + const showInvalidReportsCaveat = coverage.invalidReportCount > 0; + + // Display total for the Total tokens stat. + const displayTotal = deriveDisplayTotal(agent.usage); + const { cacheReadTokens, cacheWriteTokens, freshInputTokens } = agent.usage; + const showCacheBreakdown = hasKnownCacheData(agent.usage); + + return ( + +
+ + + + +
+ + {showCacheBreakdown ? ( +
+

+ Input breakdown +

+
+ + + +
+
+ ) : null} + + {agent.buckets.length > 0 ? ( +
+

Daily usage

+ +
+ ) : null} + + {models.length > 0 ? ( +
+

By model

+ {models.map((model) => ( + + ))} +
+ ) : null} + +
+

+ {agent.reportCount} reported turn{agent.reportCount === 1 ? "" : "s"} + {" · "} + {formatCoverageRange(coverage)} +

+ {showUnknownIntervalsCaveat ? ( +

+ Some input/output usage could not be counted and is omitted rather + than shown as zero. +

+ ) : null} + {showInvalidReportsCaveat ? ( +

+ {coverage.invalidReportCount === 1 + ? "1 report" + : `${coverage.invalidReportCount} reports`}{" "} + excluded: reports with an unreadable timestamp or a cumulative total + missing its session are not assigned to any day. +

+ ) : null} +
+
+ ); +} + +function TokenStat({ + field, + label, + testId, +}: { + field: { value: string | null; incomplete: boolean }; + label: string; + testId?: string; +}) { + const parsed = parseTokenCount(field.value); + return ( + + ); +} + +/** + * Total-tokens stat that falls back to an `≈` approximation (in+out) when + * the genuine total is unavailable. The `≈` prefix keeps the approximation + * honest without hiding that real token activity was counted. + */ +function ApproxTokenStat({ + displayTotal, + label, +}: { + displayTotal: DisplayTotal; + label: string; +}) { + const display = + displayTotal.kind === "exact" + ? formatTokenCountExact(displayTotal.value) + : displayTotal.kind === "approximate" + ? `≈ ${formatTokenCountExact(displayTotal.value)}` + : null; + return ( + + ); +} + +function UsageStat({ + display, + isPartial, + label, + testId, +}: { + display: string | null; + isPartial: boolean; + label: string; + testId?: string; +}) { + return ( +
+

{label}

+

+ {display ?? "—"} +

+ {isPartial ? Partial : null} +
+ ); +} + +/** + * Human-readable coverage range for the focused view's footer, from the + * exact first/last reported timestamps the backend already computes + * (plan:329's "coverage dates"). `null` on either end means no reported row + * fell in this window (the caller only renders this once `agent` exists, + * so both are actually set in practice, but the fallback stays honest). + */ +function formatCoverageRange(coverage: AgentUsageSeries["coverage"]): string { + const { firstReportedAt, lastReportedAt } = coverage; + if (firstReportedAt === null || lastReportedAt === null) { + return "coverage unknown"; + } + if (firstReportedAt === lastReportedAt) { + return `reported ${formatCoverageDate(firstReportedAt)}`; + } + return `${formatCoverageDate(firstReportedAt)} – ${formatCoverageDate(lastReportedAt)}`; +} + +/** + * One "By model" row: the model/harness label with its display total and + * Partial badge, plus a muted cache/fresh-input breakdown line beneath it + * when any cache subset is known. The breakdown is computed once and omitted + * entirely when no subset was reported, so old-harness models read no + * differently than before. + */ +function FocusedModelRow({ model }: { model: AgentUsageModel }) { + const cacheBreakdown = formatModelCacheBreakdown(model); + return ( +
+
+ + {model.model ?? "Unknown model"} + {model.harness !== null ? ( + + {model.harness} + + ) : null} + + + {isUnknownField(model.usage.totalTokens) + ? formatModelIndependentFields(model) + : formatTokenCountExact( + parseTokenCount(model.usage.totalTokens.value) ?? 0n, + )} + {isPartialField(model.usage.totalTokens) || + isModelIoPartial(model) ? ( + + Partial + + ) : null} + +
+ {cacheBreakdown !== null ? ( +

+ {cacheBreakdown} +

+ ) : null} +
+ ); +} + +/** + * Render known model I/O fields when the model total is unknown — never + * collapses to "No usage reported" when input or output is actually known + * (A2 per-field completeness). Mirrors `formatIndependentFields` in the + * overview row. + */ +function formatModelIndependentFields(model: AgentUsageModel): string { + const input = parseTokenCount(model.usage.inputTokens.value); + const output = parseTokenCount(model.usage.outputTokens.value); + if (input !== null || output !== null) { + const parts: string[] = []; + if (input !== null) parts.push(`in ${formatTokenCountCompact(input)}`); + if (output !== null) parts.push(`out ${formatTokenCountCompact(output)}`); + return parts.join(" · "); + } + return "No usage reported"; +} + +/** + * True when a model has no known total but its displayed I/O fields carry + * incomplete truth — so the Partial badge must still appear (A2). + */ +function isModelIoPartial(model: AgentUsageModel): boolean { + return ( + isUnknownField(model.usage.totalTokens) && + (isPartialField(model.usage.inputTokens) || + isPartialField(model.usage.outputTokens)) + ); +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageRangeTabs.tsx b/desktop/src/features/agent-usage/ui/AgentUsageRangeTabs.tsx new file mode 100644 index 00000000000..da93781fc03 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageRangeTabs.tsx @@ -0,0 +1,211 @@ +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { + buildLocalDayBoundaries, + formatLocalDate, + validateCustomRange, + type UsageRange, +} from "../lib/agentUsage"; + +const PRESET_DAYS = [1, 7, 30] as const; + +type PresetDays = (typeof PRESET_DAYS)[number]; + +/** + * Window selector shared by the Agents-overview and per-agent usage views: + * the `1d`/`7d`/`30d` presets plus a `Custom` tab whose popover takes an + * arbitrary inclusive start/end date pair. + * + * Selecting `Custom` opens the picker without changing the active range — + * the range only moves once a valid pair is applied, so an in-progress edit + * never issues a query. Validation is local ({@link validateCustomRange}), + * so the user sees "pick a range of 366 days or fewer" rather than the + * backend's fail-closed arity error. + * + * `testIdPrefix` keeps the two mounted instances addressable independently + * (`agent-usage-window-*` on the overview, `agent-usage-focused-window-*` in + * the profile panel). + */ +export function AgentUsageRangeTabs({ + onRangeChange, + range, + testIdPrefix, +}: { + onRangeChange: (range: UsageRange) => void; + range: UsageRange; + testIdPrefix: string; +}) { + const [pickerOpen, setPickerOpen] = React.useState(false); + const customTriggerRef = React.useRef(null); + + return ( + // `PopoverAnchor`, not `PopoverTrigger`: the trigger would spread its own + // `data-state` ("open"/"closed") onto the tab and clobber the tab's + // "active"/"inactive" state. The anchor only positions, so the picker + // opens from an explicit click and the tab keeps its own state. + + { + const days = Number(value); + if (isPresetDays(days)) onRangeChange({ kind: "preset", days }); + }} + value={range.kind === "preset" ? String(range.days) : "custom"} + > + + {PRESET_DAYS.map((days) => ( + + {days}d + + ))} + + setPickerOpen(true)} + ref={customTriggerRef} + value="custom" + > + Custom + + + + + + { + // No `PopoverTrigger` to return focus to, so restore it manually. + event.preventDefault(); + customTriggerRef.current?.focus(); + }} + > + { + onRangeChange(applied); + setPickerOpen(false); + }} + range={range} + testIdPrefix={testIdPrefix} + /> + + + ); +} + +function CustomRangeForm({ + onApply, + range, + testIdPrefix, +}: { + onApply: (range: UsageRange) => void; + range: UsageRange; + testIdPrefix: string; +}) { + // Seeded once per mount; Radix unmounts the popover's content when it + // closes, so each open re-seeds from the range that is active then. + const [draft, setDraft] = React.useState(() => initialDraft(range)); + const { startDate, endDate } = draft; + + const validation = validateCustomRange(startDate, endDate); + const today = formatLocalDate(new Date()); + + return ( +
{ + event.preventDefault(); + if (validation.ok) onApply({ kind: "custom", startDate, endDate }); + }} + > +

Custom range

+
+ + setDraft((current) => ({ + ...current, + startDate: event.target.value, + })) + } + type="date" + value={startDate} + /> + to + + setDraft((current) => ({ ...current, endDate: event.target.value })) + } + type="date" + value={endDate} + /> +
+ {validation.ok ? ( +

+ {validation.days} day{validation.days === 1 ? "" : "s"} selected. +

+ ) : ( +

+ {validation.message} +

+ )} + +
+ ); +} + +function isPresetDays(days: number): days is PresetDays { + return (PRESET_DAYS as readonly number[]).includes(days); +} + +/** + * Pre-fill for the picker: the active custom range when there is one, + * otherwise the span the active preset already covers, so applying without + * edits is a no-op rather than an empty form. Endpoints come from + * {@link buildLocalDayBoundaries} so the pre-filled dates are exactly the + * civil days the preset queried. + */ +function initialDraft(range: UsageRange): { + startDate: string; + endDate: string; +} { + if (range.kind === "custom") { + return { startDate: range.startDate, endDate: range.endDate }; + } + const boundaries = buildLocalDayBoundaries(range.days); + // Boundaries close the final day, so the last covered day starts at the + // second-to-last boundary. + const first = boundaries[0] ?? 0; + const lastDayStart = boundaries[boundaries.length - 2] ?? first; + return { + startDate: formatLocalDate(new Date(first * 1_000)), + endDate: formatLocalDate(new Date(lastDayStart * 1_000)), + }; +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx b/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx new file mode 100644 index 00000000000..d2ab86246fa --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx @@ -0,0 +1,310 @@ +import * as React from "react"; +import { RefreshCw } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import type { AgentUsage, AgentUsageSeries } from "@/shared/api/tauriArchive"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { SectionHeader } from "@/shared/ui/PageHeader"; +import { Progress } from "@/shared/ui/progress"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { useAgentUsageSeries } from "../hooks"; +import { + bigintRatio, + DEFAULT_USAGE_RANGE, + deriveDisplayTotal, + describeRange, + formatCoverageDate, + formatTokenCountCompact, + sortAgentsByDisplayTotal, + sumKnownBucketTotals, + type UsageRange, +} from "../lib/agentUsage"; +import { AgentUsageDailyBars } from "./AgentUsageDailyBars"; +import { AgentUsageRangeTabs } from "./AgentUsageRangeTabs"; + +/** + * Compact "Usage" section on the Agents page: local NIP-AM usage totals for + * the selected window (1d/7d/30d preset or a custom date range), broken down + * per agent, with a click-through to the per-agent focused view in the + * profile panel (M4/A9/A13, frozen Rev 3 plan). + */ +export function AgentUsageSection({ + onOpenAgentProfile, +}: { + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; +}) { + const [range, setRange] = React.useState(DEFAULT_USAGE_RANGE); + const query = useAgentUsageSeries({ range }); + const { onOpenSettings } = useAppShell(); + + const agents = React.useMemo( + () => sortAgentsByDisplayTotal(query.data?.agents ?? []), + [query.data?.agents], + ); + const pubkeys = React.useMemo( + () => agents.map((agent) => agent.agentPubkey), + [agents], + ); + const usersBatchQuery = useUsersBatchQuery(pubkeys, { + enabled: pubkeys.length > 0, + }); + + return ( +
+ + } + description="Locally archived, agent-reported usage." + title="Usage" + /> + + {query.isLoading ? ( + + ) : query.isError ? ( + + + Couldn't load usage data. + + + + ) : query.data ? ( + + ) : null} +
+ ); +} + +function AgentUsageSkeleton() { + return ( + + + + + + + ); +} + +function AgentUsageCard({ + agents, + onOpenAgentProfile, + onOpenSettings, + profiles, + range, + series, +}: { + agents: AgentUsage[]; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; + onOpenSettings: ((section: "local-archive") => void) | null; + profiles: UserProfileLookup | undefined; + range: UsageRange; + series: AgentUsageSeries; +}) { + const hasRows = agents.length > 0; + const collectionOff = !series.collectionEnabled; + const hasRetainedData = series.coverage.reportCount > 0; + // True when the window has in-window invalid rows but no valid/bucketed rows. + // These rows are correctly excluded from buckets (A5/A11) but the window is + // not empty — coverage.hasUnknownUsage reflects this via the F1 roll-up. + const hasInvalidOnlyInWindow = + !hasRows && + series.collectionEnabled && + series.coverage.invalidReportCount > 0; + + // Relative bars are decorative (aria-hidden, per plan) — scale each agent's + // display total (exact or approximate) against the largest such value in the + // current window so the sorted-by-display-total list also reads as a bar chart. + const maxDisplayValue = React.useMemo( + () => + agents.reduce((max, agent) => { + const dt = deriveDisplayTotal(agent.usage); + return dt.value !== null && dt.value > max ? dt.value : max; + }, 0n), + [agents], + ); + + const overallTotal = React.useMemo( + () => sumKnownBucketTotals(series.buckets), + [series.buckets], + ); + + return ( + + {series.buckets.length > 0 ? ( +
+
+

Daily usage

+ + {overallTotal.kind === "exact" + ? `${formatTokenCountCompact(overallTotal.value)} tokens` + : overallTotal.kind === "approximate" + ? `≈ ${formatTokenCountCompact(overallTotal.value)} tokens` + : hasInvalidOnlyInWindow + ? "Usage uncountable" + : "No usage reported"} + {(overallTotal.kind !== "unknown" && overallTotal.partial) || + hasInvalidOnlyInWindow ? ( + + Partial + + ) : null} + +
+ +
+ ) : null} + + {collectionOff ? ( + + + + {hasRetainedData + ? `Collection off · data through ${formatCoverageDate( + series.coverage.lastArchivedAt, + )}` + : "Local usage collection is off."} + + + + + ) : null} + + {hasRows ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : ( +

+ {collectionOff + ? "Turn on collection to start tracking agent usage." + : hasInvalidOnlyInWindow + ? `Usage was collected in ${describeRange(range)} but could not be counted — reports with unreadable timestamps or missing session totals are excluded.` + : `No locally archived usage in ${describeRange(range)}. Usage appears after an agent completes a usage-reporting turn.`} +

+ )} +
+ ); +} + +function AgentUsageRow({ + agent, + label, + maxDisplayValue, + onOpenAgentProfile, + profileAvatarUrl, + range, +}: { + agent: AgentUsage; + label: string; + maxDisplayValue: bigint; + onOpenAgentProfile: ( + pubkey: string, + options?: ProfilePanelOpenOptions, + ) => void; + profileAvatarUrl: string | null; + range: UsageRange; +}) { + const dt = deriveDisplayTotal(agent.usage); + + const trailing = + dt.kind === "exact" + ? formatTokenCountCompact(dt.value) + : dt.kind === "approximate" + ? `≈ ${formatTokenCountCompact(dt.value)}` + : "No usage reported"; + + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 361199c50d8..bbb528c5a87 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -72,7 +72,8 @@ export function AgentsScreen() { profile: pubkey, profilePersona: null, profileTab: options?.tab === "info" ? null : (options?.tab ?? null), - profileView: null, + profileView: + options?.view === "summary" ? null : (options?.view ?? null), }); }, [applyPatch], diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index e1e1f37f35f..1c997e70f19 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -4,6 +4,8 @@ import { consumePendingSnapshotImport, subscribeSnapshotImport, } from "@/features/agents/openSnapshotImportFromUrlEvent"; +import { AgentUsageSection } from "@/features/agent-usage/ui/AgentUsageSection"; +import { AgentProviderAllowanceSection } from "@/features/provider-usage/ui/AgentProviderAllowanceSection"; import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog"; import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; @@ -291,6 +293,14 @@ export function AgentsView() { personas={personas.libraryPersonas} teams={teamActions.teams} /> + + + + { + openProfilePanel?.(pubkey, options); + }} + />
diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index f02b5aee097..db837abe05f 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -61,6 +61,8 @@ import { MemoryFocusedView, } from "@/features/profile/ui/UserProfilePanelFocusedViews"; import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; +import { AgentUsageFocusedView } from "@/features/agent-usage/ui/AgentUsageFocusedView"; +import { useUsageIngress } from "@/features/agent-usage/hooks"; import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProfileAgentActions"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; @@ -292,18 +294,9 @@ export function UserProfilePanel({ const isBot = Boolean(relayAgent || managedAgent || resolvedPersona) || isAgentByOaOwner; const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null); - // Does THIS desktop hold the agent's seckey (or is this an editable persona)? - // Gates edit (which needs the key) and grants owner access when managed locally. + // UI-only ownership signals; real access boundaries remain server-side. const isOwner = resolvedPersona ? true : managedAgentOwner; - // Is the viewer the agent's declared owner (NIP-OA `ownerPubkey == me`)? This - // is the right signal for viewing owner-scoped data (activity feed, memory): - // the relay routes and the client decrypts those frames with the owner's OWN - // key, so the agent's seckey is never needed. Computed here (before the gates - // that consume it) so visibility keys off declared ownership, not key custody. const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey); - // The viewer may see owner-scoped data if they declared-own the agent OR they - // manage it locally (older agents may not advertise an owner pubkey). Every - // real boundary is server-side, so this only controls what UI we paint. const viewerIsOwner = isCurrentUserOwner || isOwner === true; const activityAgent = React.useMemo( @@ -318,7 +311,6 @@ export function UserProfilePanel({ }), [effectivePubkey, isBot, managedAgent, profile, relayAgent, viewerIsOwner], ); - // Observer ingestion is owner-global across local and declared-owned agents. const canEditAgent = Boolean(isOwner && (managedAgent ?? resolvedPersona)); const isSelf = currentPubkey !== undefined && @@ -328,6 +320,8 @@ export function UserProfilePanel({ viewerIsOwner && Boolean(effectivePubkey) && canOpenAgentActivity(effectivePubkey); + const canViewUsage = viewerIsOwner && isBot && Boolean(effectivePubkey); + const usageIngressTrailing = useUsageIngress(effectivePubkey, canViewUsage); const canOpenAgentLogs = isOwner === true && managedAgent?.backend.type === "local"; const canInstantiateAgent = @@ -618,8 +612,7 @@ export function UserProfilePanel({ [deletePersonaMutation.mutateAsync, onClose], ); - // Count of managed-agent instances backed by the persona being deleted. - // Shown in the confirm dialog so the user knows what will be cascade-deleted. + // Count of instances backed by the persona being deleted (shown in confirm dialog). const personaDeleteInstanceCount = React.useMemo( () => personaToDelete @@ -787,6 +780,8 @@ export function UserProfilePanel({ canInstantiateAgent={canInstantiateAgent} canOpenAgentLogs={canOpenAgentLogs} canViewActivity={canViewActivity} + canViewUsage={canViewUsage} + usageIngressTrailing={usageIngressTrailing} channelCount={profileChannels.length} channelIdToName={channelIdToName} channels={profileChannels} @@ -836,6 +831,7 @@ export function UserProfilePanel({ onOpenActivity={handleOpenActivity} onOpenChannel={handleOpenChannel} onOpenDiagnostics={() => setView("diagnostics")} + onOpenUsage={() => setView("usage")} onStickyChromeChange={handleStickyChromeChange} onTabChange={setTab} presenceStatus={presenceStatus} @@ -853,6 +849,13 @@ export function UserProfilePanel({ viewerIsOwner={viewerIsOwner} /> ) : null} + {view === "usage" && effectivePubkey ? ( + setView("summary", { replace: true })} + /> + ) : null} {view === "info" ? ( ) : null} @@ -932,45 +935,43 @@ export function UserProfilePanel({ /> ) : null; const personaDialogs = ( - <> - setPersonaToDelete(null)} - onCloseDialog={() => setPersonaDialogState(null)} - onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)} - onConfirmDelete={(selectedPersona) => { - void handleConfirmDeletePersona(selectedPersona); - }} - onExportSnapshot={setPersonaToExportSnapshot} - onSubmit={handleSubmitPersona} - /> - + setPersonaToDelete(null)} + onCloseDialog={() => setPersonaDialogState(null)} + onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)} + onConfirmDelete={(selectedPersona) => { + void handleConfirmDeletePersona(selectedPersona); + }} + onExportSnapshot={setPersonaToExportSnapshot} + onSubmit={handleSubmitPersona} + /> ); return ( ; channels: ProfileChannelLink[]; @@ -103,6 +105,7 @@ export type ProfileSummaryViewProps = { onOpenActivity: (channelId?: string | null) => void; onOpenChannel: (channelId: string) => void; onOpenDiagnostics: () => void; + onOpenUsage: () => void; onStickyChromeChange: (state: { active: boolean; height: number }) => void; onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void; presenceStatus: "online" | "away" | "offline" | undefined; @@ -137,6 +140,8 @@ export function ProfileSummaryView({ canEditAgent, canOpenAgentLogs, canViewActivity, + canViewUsage, + usageIngressTrailing, channelCount, channelIdToName, channels, @@ -178,6 +183,7 @@ export function ProfileSummaryView({ onOpenActivity, onOpenChannel, onOpenDiagnostics, + onOpenUsage, onStickyChromeChange, onTabChange, presenceStatus, @@ -241,11 +247,13 @@ export function ProfileSummaryView({ diagnosticsFields.some((field) => field.label !== "Status") || canOpenAgentLogs; const showActivityIngress = canViewActivity; + const showUsageIngress = canViewUsage; const showInfoTab = agentInfoFields.length > 0 || runtimeFields.length > 0 || isArchived || showActivityIngress || + showUsageIngress || showInstructionBlock || managedAgent !== undefined || !showRuntimeTab; @@ -521,8 +529,11 @@ export function ProfileSummaryView({ onDuplicateAgent={onDuplicateAgent} onExportAgent={onExportAgent} onOpenActivity={onOpenActivity} + onOpenUsage={onOpenUsage} pubkey={pubkey} showActivityIngress={showActivityIngress} + showUsageIngress={showUsageIngress} + usageIngressTrailing={usageIngressTrailing} showInstructionBlock={showInstructionBlock} /> ) : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 842ffd58ffb..8f9ebfcfe2d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import type { LucideIcon } from "lucide-react"; import { Archive, + BarChart3, ChevronRight, Info, MessageSquare, @@ -213,9 +214,12 @@ export function ProfileInfoTabContent({ onExportAgent, onOpenActivity, onEditAgent, + onOpenUsage, pubkey, showActivityIngress, showInstructionBlock, + showUsageIngress, + usageIngressTrailing, }: { activeTurns: ActiveTurnSummary[]; activityAgent: ProfileActivityAgent | null; @@ -234,9 +238,12 @@ export function ProfileInfoTabContent({ onExportAgent?: () => void; onEditAgent: () => void; onOpenActivity: (channelId?: string | null) => void; + onOpenUsage: () => void; pubkey: string | null; showActivityIngress: boolean; showInstructionBlock: boolean; + showUsageIngress: boolean; + usageIngressTrailing: string | undefined; }) { const infoFields: ProfileField[] = isArchived ? [ @@ -265,7 +272,8 @@ export function ProfileInfoTabContent({ !onDuplicateAgent && !onExportAgent && !showActivityIngress && - !showInstructionBlock + !showInstructionBlock && + !showUsageIngress ) { return null; } @@ -291,6 +299,15 @@ export function ProfileInfoTabContent({ /> ) ) : null} + {showUsageIngress ? ( + + ) : null} {hasInfoFields || showInstructionBlock ? ( {showInstructionBlock ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 0c983fa3e84..b449401b2a5 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -200,6 +200,7 @@ test("parseProfilePanelView accepts all profile panel subviews", () => { "memories", "channels", "logs", + "usage", ]) { assert.equal(parseProfilePanelView(view), view); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index be1a57c112e..71575030ba2 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -25,7 +25,8 @@ export type ProfilePanelView = | "diagnostics" | "memories" | "channels" - | "logs"; + | "logs" + | "usage"; export type ProfilePanelTab = "info" | "runtime" | "channels" | "memories"; @@ -38,6 +39,7 @@ export const PROFILE_PANEL_VIEW_TITLES: Record = { memories: "Memories", channels: "Channels", logs: "Harness log", + usage: "Usage", }; const PROFILE_PANEL_VIEWS = new Set( diff --git a/desktop/src/features/provider-usage/agentProviderUsage.test.mjs b/desktop/src/features/provider-usage/agentProviderUsage.test.mjs new file mode 100644 index 00000000000..13451a8c80b --- /dev/null +++ b/desktop/src/features/provider-usage/agentProviderUsage.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + constrainingProviderWindow, + providerAllowanceLevel, + resolveAgentProviderUsage, +} from "./agentProviderUsage.ts"; + +const runtimes = [ + { + id: "codex", + label: "Codex", + command: "/opt/buzz/codex-acp", + providerUsageId: "codex", + }, + { + id: "custom", + label: "Custom runtime", + command: "custom-acp", + providerUsageId: null, + }, +]; + +test("resolves provider allowance from runtime catalog metadata", () => { + assert.deepEqual( + resolveAgentProviderUsage( + { runtime: "codex", agentCommand: "old-command" }, + runtimes, + ), + { providerUsageId: "codex", runtimeLabel: "Codex" }, + ); +}); + +test("falls back to the effective command for inherited legacy records", () => { + assert.deepEqual( + resolveAgentProviderUsage( + { runtime: null, agentCommand: "/opt/buzz/codex-acp" }, + runtimes, + ), + { providerUsageId: "codex", runtimeLabel: "Codex" }, + ); + assert.deepEqual( + resolveAgentProviderUsage( + { runtime: null, agentCommand: "unlisted-acp" }, + runtimes, + ), + { providerUsageId: null, runtimeLabel: "unlisted-acp" }, + ); +}); + +test("labels 80%, 90%, and exhausted allowance thresholds", () => { + assert.equal(providerAllowanceLevel(21), "healthy"); + assert.equal(providerAllowanceLevel(20), "low"); + assert.equal(providerAllowanceLevel(10), "critical"); + assert.equal(providerAllowanceLevel(0), "exhausted"); +}); + +test("selects the window with the least remaining allowance", () => { + const windows = [ + { + id: "weekly", + label: "Weekly", + usedPercent: 40, + remainingPercent: 60, + resetsAt: null, + durationMinutes: null, + }, + { + id: "five-hour", + label: "5 hour", + usedPercent: 88, + remainingPercent: 12, + resetsAt: null, + durationMinutes: 300, + }, + ]; + assert.equal(constrainingProviderWindow(windows)?.id, "five-hour"); + assert.equal(constrainingProviderWindow([]), null); +}); diff --git a/desktop/src/features/provider-usage/agentProviderUsage.ts b/desktop/src/features/provider-usage/agentProviderUsage.ts new file mode 100644 index 00000000000..e040fc322ad --- /dev/null +++ b/desktop/src/features/provider-usage/agentProviderUsage.ts @@ -0,0 +1,64 @@ +import type { AcpRuntimeCatalogEntry, ManagedAgent } from "@/shared/api/types"; +import type { + ProviderUsageId, + ProviderUsageWindow, +} from "@/shared/api/tauriProviderUsage"; + +export type AgentProviderUsageResolution = { + providerUsageId: ProviderUsageId | null; + runtimeLabel: string; +}; + +export type ProviderAllowanceLevel = + | "healthy" + | "low" + | "critical" + | "exhausted"; + +/** + * Resolve the effective runtime from the backend catalog rather than teaching + * React about specific harness ids. Older managed-agent records can lack a + * runtime id, so the resolved command remains the compatibility fallback. + */ +export function resolveAgentProviderUsage( + agent: Pick, + runtimes: AcpRuntimeCatalogEntry[], +): AgentProviderUsageResolution { + const runtimeId = agent.runtime?.trim(); + const command = agent.agentCommand.trim(); + const runtime = + (runtimeId + ? runtimes.find((candidate) => candidate.id === runtimeId) + : undefined) ?? + runtimes.find((candidate) => candidate.command?.trim() === command) ?? + runtimes.find((candidate) => candidate.id === command); + + return { + providerUsageId: runtime?.providerUsageId ?? null, + runtimeLabel: runtime?.label ?? (command || "Runtime unavailable"), + }; +} + +/** 80%, 90%, and 100% consumed thresholds expressed as allowance remaining. */ +export function providerAllowanceLevel( + remainingPercent: number, +): ProviderAllowanceLevel { + if (remainingPercent <= 0) return "exhausted"; + if (remainingPercent <= 10) return "critical"; + if (remainingPercent <= 20) return "low"; + return "healthy"; +} + +export function constrainingProviderWindow( + windows: ProviderUsageWindow[], +): ProviderUsageWindow | null { + return ( + windows.reduce( + (lowest, window) => + lowest === null || window.remainingPercent < lowest.remainingPercent + ? window + : lowest, + null, + ) ?? null + ); +} diff --git a/desktop/src/features/provider-usage/hooks.ts b/desktop/src/features/provider-usage/hooks.ts new file mode 100644 index 00000000000..65642d83267 --- /dev/null +++ b/desktop/src/features/provider-usage/hooks.ts @@ -0,0 +1,67 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + getProviderUsage, + listProviderUsageCapabilities, +} from "@/shared/api/tauriProviderUsage"; +import { useFeatureEnabled } from "@/shared/features"; +import { + resolveProviderUsagePreference, + useProviderUsagePreference, +} from "./providerUsagePreference"; + +export const PROVIDER_USAGE_STALE_MS = 5 * 60 * 1_000; + +export function providerUsageProductLabel(provider: string): string { + if (provider === "codex") return "Codex"; + if (provider === "claude") return "Claude"; + if (provider === "grok") return "Grok"; + return "Provider"; +} + +/** + * Shared provider-allowance query. The capability read is cheap and must + * resolve before Buzz starts a provider process; disabling the preview keeps + * both IPC calls dormant. Every mounted consumer shares the provider-scoped + * React Query entries, so the Agents dashboard and chrome indicator never + * multiply app-server reads. + */ +export function useProviderUsageSnapshot() { + const featureEnabled = useFeatureEnabled("providerUsage"); + const preference = useProviderUsagePreference(); + const capabilitiesQuery = useQuery({ + queryKey: ["provider-usage-capabilities"], + queryFn: listProviderUsageCapabilities, + enabled: featureEnabled, + staleTime: Number.POSITIVE_INFINITY, + }); + const provider = resolveProviderUsagePreference( + preference, + capabilitiesQuery.data, + ); + const capability = capabilitiesQuery.data?.find( + (candidate) => candidate.id === provider, + ); + const adapterAvailable = capability?.availability === "available"; + const query = useQuery({ + queryKey: ["provider-usage", provider], + queryFn: () => getProviderUsage(provider), + enabled: featureEnabled && adapterAvailable, + staleTime: PROVIDER_USAGE_STALE_MS, + refetchInterval: PROVIDER_USAGE_STALE_MS, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + retry: 1, + }); + + return { + adapterAvailable, + capabilitiesQuery, + capability, + featureEnabled, + preference, + productLabel: providerUsageProductLabel(provider), + provider, + query, + }; +} diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.d.mts b/desktop/src/features/provider-usage/providerUsageDisplay.d.mts new file mode 100644 index 00000000000..a6fb14684b3 --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.d.mts @@ -0,0 +1,11 @@ +export type ProviderUsageTone = "healthy" | "warning" | "critical"; + +export function providerUsageTone(remainingPercent: number): ProviderUsageTone; + +export function formatTokenCount(value: number | null | undefined): string; + +export function formatUsageReset( + epochSeconds: number | null | undefined, +): string; + +export function providerUsageErrorMessage(error: unknown): string; diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.mjs b/desktop/src/features/provider-usage/providerUsageDisplay.mjs new file mode 100644 index 00000000000..df68493f625 --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.mjs @@ -0,0 +1,59 @@ +// @ts-check + +/** + * @param {number} remainingPercent + * @returns {"healthy" | "warning" | "critical"} + */ +export function providerUsageTone(remainingPercent) { + if (remainingPercent < 20) return "critical"; + if (remainingPercent <= 50) return "warning"; + return "healthy"; +} + +/** + * @param {number | null | undefined} value + * @returns {string} + */ +export function formatTokenCount(value) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +/** + * @param {number | null | undefined} epochSeconds + * @returns {string} + */ +export function formatUsageReset(epochSeconds) { + if (typeof epochSeconds !== "number" || !Number.isFinite(epochSeconds)) { + return "Reset unavailable"; + } + return new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(epochSeconds * 1000)); +} + +/** + * @param {unknown} error + * @returns {string} + */ +export function providerUsageErrorMessage(error) { + const code = typeof error === "string" ? error : String(error ?? ""); + if (code.includes("codex_not_installed")) return "Codex is not installed"; + if (code.includes("codex_not_authenticated")) { + return "Sign in with Codex to show usage"; + } + if (code.includes("protocol_unsupported")) { + return "Update Codex to show usage"; + } + if (code.includes("response_too_large")) { + return "Codex returned an unsafe response"; + } + return "Usage temporarily unavailable"; +} diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs b/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs new file mode 100644 index 00000000000..1c90bb637e6 --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatTokenCount, + providerUsageErrorMessage, + providerUsageTone, +} from "./providerUsageDisplay.mjs"; + +test("providerUsageTone follows the remaining-usage thresholds", () => { + assert.equal(providerUsageTone(62), "healthy"); + assert.equal(providerUsageTone(50), "warning"); + assert.equal(providerUsageTone(20), "warning"); + assert.equal(providerUsageTone(19), "critical"); +}); + +test("formatTokenCount stays compact and handles missing values", () => { + assert.equal(formatTokenCount(null), "—"); + assert.match(formatTokenCount(13_597_623_776), /13[.,]?6B/i); +}); + +test("providerUsageErrorMessage never exposes raw app-server details", () => { + assert.equal( + providerUsageErrorMessage( + "codex_not_authenticated: alice@example.com should not render", + ), + "Sign in with Codex to show usage", + ); + assert.equal( + providerUsageErrorMessage("unknown failure with local path /Users/alice"), + "Usage temporarily unavailable", + ); +}); diff --git a/desktop/src/features/provider-usage/providerUsagePreference.test.mjs b/desktop/src/features/provider-usage/providerUsagePreference.test.mjs new file mode 100644 index 00000000000..a803551ec26 --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsagePreference.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const listeners = new Map(); +const values = new Map(); +globalThis.window = { + localStorage: { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }, + addEventListener: (name, listener) => listeners.set(name, listener), + removeEventListener: (name) => listeners.delete(name), + dispatchEvent: () => true, +}; + +const { + getProviderUsagePreference, + resolveProviderUsagePreference, + setProviderUsagePreference, +} = await import("./providerUsagePreference.ts"); + +test("provider preference defaults to Auto and persists supported values", () => { + assert.equal(getProviderUsagePreference(), "auto"); + setProviderUsagePreference("codex"); + assert.equal(getProviderUsagePreference(), "codex"); +}); + +test("provider preference rejects malformed storage", () => { + values.set("buzz-provider-usage-preference", "secret-provider"); + assert.equal(getProviderUsagePreference(), "auto"); +}); + +test("provider preference tolerates unavailable storage", () => { + const original = globalThis.window.localStorage.getItem; + globalThis.window.localStorage.getItem = () => { + throw new Error("unavailable"); + }; + assert.equal(getProviderUsagePreference(), "auto"); + globalThis.window.localStorage.getItem = original; +}); + +test("Auto resolves to the supported Codex adapter", () => { + assert.equal(resolveProviderUsagePreference("auto"), "codex"); + assert.equal(resolveProviderUsagePreference("grok"), "grok"); + assert.equal( + resolveProviderUsagePreference("auto", [ + { + id: "claude", + name: "Claude", + availability: "available", + detail: "Future supported adapter", + }, + ]), + "claude", + ); +}); diff --git a/desktop/src/features/provider-usage/providerUsagePreference.ts b/desktop/src/features/provider-usage/providerUsagePreference.ts new file mode 100644 index 00000000000..c88596590ca --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsagePreference.ts @@ -0,0 +1,71 @@ +import { useSyncExternalStore } from "react"; + +import type { + ProviderUsageCapability, + ProviderUsageId, + ProviderUsagePreference, +} from "@/shared/api/tauriProviderUsage"; + +const STORAGE_KEY = "buzz-provider-usage-preference"; +const CHANGE_EVENT = "buzz-provider-usage-preference-change"; + +function parsePreference(value: string | null): ProviderUsagePreference { + if ( + value === "codex" || + value === "claude" || + value === "grok" || + value === "auto" + ) { + return value; + } + return "auto"; +} + +export function getProviderUsagePreference(): ProviderUsagePreference { + if (typeof window === "undefined") return "auto"; + try { + return parsePreference(window.localStorage.getItem(STORAGE_KEY)); + } catch { + return "auto"; + } +} + +export function setProviderUsagePreference( + preference: ProviderUsagePreference, +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, preference); + } catch { + // Device-level UI preference. Storage failure safely falls back to Auto. + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +function subscribe(onStoreChange: () => void): () => void { + window.addEventListener(CHANGE_EVENT, onStoreChange); + window.addEventListener("storage", onStoreChange); + return () => { + window.removeEventListener(CHANGE_EVENT, onStoreChange); + window.removeEventListener("storage", onStoreChange); + }; +} + +export function useProviderUsagePreference(): ProviderUsagePreference { + return useSyncExternalStore( + subscribe, + getProviderUsagePreference, + () => "auto", + ); +} + +export function resolveProviderUsagePreference( + preference: ProviderUsagePreference, + capabilities: ProviderUsageCapability[] = [], +): ProviderUsageId { + if (preference !== "auto") return preference; + return ( + capabilities.find((capability) => capability.availability === "available") + ?.id ?? "codex" + ); +} diff --git a/desktop/src/features/provider-usage/ui/AgentProviderAllowanceSection.tsx b/desktop/src/features/provider-usage/ui/AgentProviderAllowanceSection.tsx new file mode 100644 index 00000000000..e3bc0387136 --- /dev/null +++ b/desktop/src/features/provider-usage/ui/AgentProviderAllowanceSection.tsx @@ -0,0 +1,344 @@ +import * as React from "react"; +import { RefreshCw } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import { useAcpRuntimesQuery } from "@/features/agents/hooks"; +import { + constrainingProviderWindow, + providerAllowanceLevel, + resolveAgentProviderUsage, + type ProviderAllowanceLevel, +} from "@/features/provider-usage/agentProviderUsage"; +import { useProviderUsageSnapshot } from "@/features/provider-usage/hooks"; +import { + formatUsageReset, + providerUsageErrorMessage, +} from "@/features/provider-usage/providerUsageDisplay.mjs"; +import type { ManagedAgent } from "@/shared/api/types"; +import type { + ProviderUsageId, + ProviderUsageWindow, +} from "@/shared/api/tauriProviderUsage"; +import { Alert, AlertDescription } from "@/shared/ui/alert"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { SectionHeader } from "@/shared/ui/PageHeader"; +import { Progress } from "@/shared/ui/progress"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { cn } from "@/shared/lib/cn"; + +const allowancePresentation: Record< + ProviderAllowanceLevel, + { + badge: "destructive" | "success" | "warning"; + label: string; + progress: string; + } +> = { + healthy: { + badge: "success", + label: "Healthy", + progress: "[&>div]:bg-emerald-500", + }, + low: { + badge: "warning", + label: "Low", + progress: "[&>div]:bg-amber-500", + }, + critical: { + badge: "destructive", + label: "Critical", + progress: "[&>div]:bg-destructive", + }, + exhausted: { + badge: "destructive", + label: "Exhausted", + progress: "[&>div]:bg-destructive", + }, +}; + +export function AgentProviderAllowanceSection({ + agents, +}: { + agents: ManagedAgent[]; +}) { + const { onOpenSettings } = useAppShell(); + const runtimesQuery = useAcpRuntimesQuery(); + const snapshot = useProviderUsageSnapshot(); + const constrainingWindow = constrainingProviderWindow( + snapshot.query.data?.windows ?? [], + ); + const matchingAgentCount = React.useMemo( + () => + agents.filter( + (agent) => + resolveAgentProviderUsage(agent, runtimesQuery.data ?? []) + .providerUsageId === snapshot.provider, + ).length, + [agents, runtimesQuery.data, snapshot.provider], + ); + + return ( +
+ void snapshot.query.refetch()} + size="sm" + variant="outline" + > +
+ ); +} + +function ProviderAllowanceSummary({ + matchingAgentCount, + planType, + productLabel, + queryFailed, + window, + windows, +}: { + matchingAgentCount: number; + planType: string | null; + productLabel: string; + queryFailed: boolean; + window: ProviderUsageWindow; + windows: ProviderUsageWindow[]; +}) { + const level = providerAllowanceLevel(window.remainingPercent); + const presentation = allowancePresentation[level]; + const planLabel = planType + ? `${productLabel} ${planType.charAt(0).toUpperCase()}${planType.slice(1)}` + : productLabel; + + return ( +
+ {queryFailed ? ( + + + Last successful allowance shown; the latest refresh failed. + + + ) : null} +
+
+

{planLabel}

+

+ Account-wide allowance shared by {matchingAgentCount}{" "} + {matchingAgentCount === 1 ? "matching agent" : "matching agents"} +

+
+ {presentation.label} +
+
+
+ + {window.remainingPercent}% remaining + + + {window.usedPercent}% used + +
+ +

+ {window.label} · Resets {formatUsageReset(window.resetsAt)} +

+
+ {windows.length > 1 ? ( +
+ {windows.map((candidate) => ( +
+
+ {candidate.label} +
+
+ {candidate.remainingPercent}% remaining +
+
+ Resets {formatUsageReset(candidate.resetsAt)} +
+
+ ))} +
+ ) : null} +
+ ); +} + +function ProviderAllowanceUnavailable({ + adapterAvailable, + error, + isLoading, + productLabel, +}: { + adapterAvailable: boolean; + error: unknown; + isLoading: boolean; + productLabel: string; +}) { + if (isLoading) { + return ; + } + return ( + + + Unavailable from provider.{" "} + {adapterAvailable + ? providerUsageErrorMessage(error) + : `${productLabel} does not expose a supported local allowance source on this device.`} + + + ); +} + +function AgentAllowanceRow({ + agent, + provider, + runtimes, + window, +}: { + agent: ManagedAgent; + provider: ProviderUsageId; + runtimes: Parameters[1]; + window: ProviderUsageWindow | null; +}) { + const resolution = resolveAgentProviderUsage(agent, runtimes); + const hasAllowance = + resolution.providerUsageId === provider && window !== null; + const detail = [ + resolution.runtimeLabel, + agent.provider?.trim() || "Provider not reported", + agent.model?.trim() || "Default model", + ].join(" · "); + + return ( +
+
+

{agent.name}

+

{detail}

+
+ {hasAllowance ? ( +
+

+ {window.remainingPercent}% remaining +

+

+ Shared account allowance +

+
+ ) : ( +

+ Unavailable from provider +

+ )} +
+ ); +} + +function ProviderAllowanceSkeleton({ compact = false }: { compact?: boolean }) { + return ( +
+ + + +
+ ); +} diff --git a/desktop/src/features/provider-usage/ui/ProviderUsageExperimentSettings.tsx b/desktop/src/features/provider-usage/ui/ProviderUsageExperimentSettings.tsx new file mode 100644 index 00000000000..2235646b6ba --- /dev/null +++ b/desktop/src/features/provider-usage/ui/ProviderUsageExperimentSettings.tsx @@ -0,0 +1,150 @@ +import { useQuery } from "@tanstack/react-query"; +import { Check, CircleSlash2, Sparkles } from "lucide-react"; + +import { + listProviderUsageCapabilities, + type ProviderUsageCapability, + type ProviderUsagePreference, +} from "@/shared/api/tauriProviderUsage"; +import { cn } from "@/shared/lib/cn"; +import { + setProviderUsagePreference, + useProviderUsagePreference, +} from "@/features/provider-usage/providerUsagePreference"; + +const FALLBACK_CAPABILITIES: ProviderUsageCapability[] = [ + { + id: "codex", + name: "Codex", + availability: "temporarily_unavailable", + detail: "Local capability check unavailable", + }, + { + id: "claude", + name: "Claude", + availability: "unsupported", + detail: "No supported standalone personal allowance reader yet", + }, + { + id: "grok", + name: "Grok", + availability: "unsupported", + detail: "Consumer allowance is available in Grok Settings", + }, +]; + +function ProviderChoice({ + capability, + selected, + onSelect, +}: { + capability: ProviderUsageCapability; + selected: boolean; + onSelect: (preference: ProviderUsagePreference) => void; +}) { + const disabled = capability.availability !== "available"; + return ( + + ); +} + +export function ProviderUsageExperimentSettings({ + enabled, +}: { + enabled: boolean; +}) { + const preference = useProviderUsagePreference(); + const capabilitiesQuery = useQuery({ + queryKey: ["provider-usage-capabilities"], + queryFn: listProviderUsageCapabilities, + enabled, + staleTime: Number.POSITIVE_INFINITY, + }); + const capabilities = capabilitiesQuery.data ?? FALLBACK_CAPABILITIES; + + if (!enabled) return null; + + return ( +
+

+ Allowance provider +

+
+ + {capabilities.map((capability) => ( + + ))} +
+

+ Personal allowance only. Buzz stores no provider credentials or raw + usage responses and never publishes this data to Nostr. +

+
+ ); +} diff --git a/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx b/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx new file mode 100644 index 00000000000..3af07e2585d --- /dev/null +++ b/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx @@ -0,0 +1,408 @@ +import * as React from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Progress } from "@/shared/ui/progress"; +import { Spinner } from "@/shared/ui/spinner"; +import { cn } from "@/shared/lib/cn"; +import { + formatTokenCount, + formatUsageReset, + providerUsageErrorMessage, + providerUsageTone, +} from "@/features/provider-usage/providerUsageDisplay.mjs"; +import { useProviderUsageSnapshot } from "@/features/provider-usage/hooks"; + +const toneClasses = { + healthy: { + progress: "[&>div]:bg-primary", + sidebarStroke: "stroke-sidebar-primary", + }, + warning: { + progress: "[&>div]:bg-warning", + sidebarStroke: "stroke-warning", + }, + critical: { + progress: "[&>div]:bg-destructive", + sidebarStroke: "stroke-destructive", + }, +} as const; + +function UsageRing({ + isLoading, + remainingPercent, +}: { + isLoading: boolean; + remainingPercent?: number; +}) { + const sizeClass = "h-[18px] w-[18px]"; + + if (isLoading) { + return