use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use crate::domain::agent::{AgentKind, AgentModel};
use crate::domain::agent_usage::{AgentUsageRow, AgentUsageSnapshot, AgentUsageStatus};
use crate::infra::app_server::AppServerError;
pub(crate) type AgentUsageFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AgentUsageRequest {
pub available_agent_kinds: Vec<AgentKind>,
pub working_dir: PathBuf,
}
pub trait AgentUsageProbe: Send + Sync {
fn load_usage(&self, request: AgentUsageRequest) -> AgentUsageFuture<AgentUsageSnapshot>;
}
pub struct RealAgentUsageProbe;
impl AgentUsageProbe for RealAgentUsageProbe {
fn load_usage(&self, request: AgentUsageRequest) -> AgentUsageFuture<AgentUsageSnapshot> {
Box::pin(async move { load_usage_snapshot(request).await })
}
}
pub struct StaticAgentUsageProbe {
pub snapshot: AgentUsageSnapshot,
}
impl AgentUsageProbe for StaticAgentUsageProbe {
fn load_usage(&self, _request: AgentUsageRequest) -> AgentUsageFuture<AgentUsageSnapshot> {
let snapshot = self.snapshot.clone();
Box::pin(async move { snapshot })
}
}
async fn load_usage_snapshot(request: AgentUsageRequest) -> AgentUsageSnapshot {
let rows = AgentKind::ALL
.iter()
.copied()
.map(|agent_kind| load_usage_row(&request, agent_kind))
.collect::<Vec<_>>();
let mut loaded_rows = Vec::with_capacity(rows.len());
for row_future in rows {
loaded_rows.push(row_future.await);
}
AgentUsageSnapshot::new(loaded_rows)
}
async fn load_usage_row(request: &AgentUsageRequest, agent_kind: AgentKind) -> AgentUsageRow {
if !request.available_agent_kinds.contains(&agent_kind) {
return AgentUsageRow::new(agent_kind, AgentUsageStatus::MissingCli);
}
match agent_kind {
AgentKind::Codex => load_codex_usage_row(request).await,
AgentKind::Claude => AgentUsageRow::new(
agent_kind,
AgentUsageStatus::NotImplemented {
message: "Claude `/usage` exists, but Agentty does not yet have a structured \
collector."
.to_string(),
},
),
AgentKind::Gemini => AgentUsageRow::new(
agent_kind,
AgentUsageStatus::NotImplemented {
message: "Gemini `/stats model` exists, but Agentty does not yet collect it from \
ACP."
.to_string(),
},
),
}
}
async fn load_codex_usage_row(request: &AgentUsageRequest) -> AgentUsageRow {
let status = match super::app_server::load_codex_account_usage(
request.working_dir.as_path(),
AgentModel::Gpt54.as_str(),
)
.await
{
Ok(details) => AgentUsageStatus::Available(details),
Err(error) => codex_usage_error_status(&error),
};
AgentUsageRow::new(AgentKind::Codex, status)
}
fn codex_usage_error_status(error: &AppServerError) -> AgentUsageStatus {
let message = error.to_string();
if is_codex_usage_unavailable_error(&message) {
return AgentUsageStatus::Unavailable {
message: format!("Unavailable: {message}. Reopen Stats to retry."),
};
}
AgentUsageStatus::Error { message }
}
fn is_codex_usage_unavailable_error(message: &str) -> bool {
let message = message.to_ascii_lowercase();
[
"auth",
"forbidden",
"login",
"network",
"offline",
"sign in",
"timeout",
"timed out",
"unauthorized",
]
.iter()
.any(|needle| message.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::agent_usage::AgentUsageStatus;
#[tokio::test]
async fn real_probe_reports_placeholders_for_available_gemini_and_claude() {
let request = AgentUsageRequest {
available_agent_kinds: vec![AgentKind::Gemini, AgentKind::Claude],
working_dir: PathBuf::from("/tmp/project"),
};
let snapshot = RealAgentUsageProbe.load_usage(request).await;
assert_eq!(snapshot.rows.len(), 3);
assert_eq!(
snapshot.rows[0].status,
AgentUsageStatus::NotImplemented {
message: "Gemini `/stats model` exists, but Agentty does not yet collect it from \
ACP."
.to_string()
}
);
assert_eq!(
snapshot.rows[1].status,
AgentUsageStatus::NotImplemented {
message: "Claude `/usage` exists, but Agentty does not yet have a structured \
collector."
.to_string()
}
);
assert_eq!(snapshot.rows[2].status, AgentUsageStatus::MissingCli);
}
#[tokio::test]
async fn static_probe_returns_configured_snapshot() {
let expected_snapshot = AgentUsageSnapshot::new(vec![AgentUsageRow::new(
AgentKind::Codex,
AgentUsageStatus::MissingCli,
)]);
let probe = StaticAgentUsageProbe {
snapshot: expected_snapshot.clone(),
};
let request = AgentUsageRequest {
available_agent_kinds: Vec::new(),
working_dir: PathBuf::from("/tmp/project"),
};
let snapshot = probe.load_usage(request).await;
assert_eq!(snapshot, expected_snapshot);
}
#[test]
fn codex_usage_error_status_treats_auth_failures_as_unavailable() {
let error = AppServerError::Provider("Codex auth token is expired".to_string());
let status = codex_usage_error_status(&error);
assert!(matches!(
status,
AgentUsageStatus::Unavailable { ref message }
if message.contains("Codex auth token is expired")
&& message.contains("Reopen Stats to retry")
));
}
#[test]
fn codex_usage_error_status_keeps_unexpected_failures_as_errors() {
let error = AppServerError::Provider("Malformed rate-limit payload".to_string());
let status = codex_usage_error_status(&error);
assert_eq!(
status,
AgentUsageStatus::Error {
message: "Malformed rate-limit payload".to_string(),
}
);
}
}