use agent_client_protocol::schema::v1::UsageUpdate;
use sqlx::SqlitePool;
pub const MAX_USAGE_SNAPSHOT_BYTES: usize = 4 * 1024;
pub async fn persist_usage_snapshot(db: &SqlitePool, session_id: &str, usage: &UsageUpdate) {
let Ok(json) = serde_json::to_string(usage) else {
tracing::warn!("serialize usage snapshot failed");
return;
};
if json.len() > MAX_USAGE_SNAPSHOT_BYTES {
tracing::warn!(size = json.len(), "usage snapshot exceeds cap; skipping persist");
return;
}
if let Err(e) = sqlx::query(
"UPDATE sessions SET usage_json = ?1 WHERE id = ?2 \
AND (usage_json IS NULL OR usage_json <> ?1)",
)
.bind(&json)
.bind(session_id)
.execute(db)
.await
{
tracing::warn!("save usage snapshot failed: {}", e);
}
}
pub async fn load_usage_snapshot(db: &SqlitePool, session_id: &str) -> Option<serde_json::Value> {
let row =
sqlx::query_as::<_, (Option<String>,)>("SELECT usage_json FROM sessions WHERE id = ?")
.bind(session_id)
.fetch_optional(db)
.await
.ok()
.flatten()?;
let json = row.0?;
serde_json::from_str(&json).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::test_db::test_pool;
use agent_client_protocol::schema::v1::Cost;
fn usage(used: u64, size: u64) -> UsageUpdate {
UsageUpdate::new(used, size)
}
#[tokio::test]
async fn usage_snapshot_roundtrip_overwrite_and_isolation() {
let pool = test_pool().await;
assert_eq!(load_usage_snapshot(&pool, "s1").await, None);
let u1 = usage(1000, 200_000);
persist_usage_snapshot(&pool, "s1", &u1).await;
assert_eq!(
load_usage_snapshot(&pool, "s1").await,
Some(serde_json::to_value(&u1).unwrap())
);
let u2 = usage(50_000, 200_000);
persist_usage_snapshot(&pool, "s1", &u2).await;
assert_eq!(
load_usage_snapshot(&pool, "s1").await,
Some(serde_json::to_value(&u2).unwrap())
);
assert_eq!(load_usage_snapshot(&pool, "s2").await, None);
}
#[tokio::test]
async fn usage_snapshot_same_value_skips_write() {
let pool = test_pool().await;
let u = usage(1000, 200_000);
persist_usage_snapshot(&pool, "s1", &u).await;
let before: i64 =
sqlx::query_scalar("SELECT total_changes()").fetch_one(&pool).await.unwrap();
persist_usage_snapshot(&pool, "s1", &u).await;
let after: i64 =
sqlx::query_scalar("SELECT total_changes()").fetch_one(&pool).await.unwrap();
assert_eq!(before, after, "同值重推不应产生写入");
persist_usage_snapshot(&pool, "s1", &usage(1001, 200_000)).await;
let changed: i64 =
sqlx::query_scalar("SELECT total_changes()").fetch_one(&pool).await.unwrap();
assert!(changed > after, "不同值应写入");
}
#[tokio::test]
async fn usage_snapshot_skips_oversized_payload() {
let pool = test_pool().await;
let mut meta = serde_json::Map::new();
meta.insert("blob".to_string(), serde_json::json!("x".repeat(MAX_USAGE_SNAPSHOT_BYTES)));
let u = usage(1000, 200_000).meta(meta);
persist_usage_snapshot(&pool, "s1", &u).await;
assert_eq!(load_usage_snapshot(&pool, "s1").await, None);
}
#[tokio::test]
async fn load_usage_snapshot_tolerates_dirty_json() {
let pool = test_pool().await;
sqlx::query("UPDATE sessions SET usage_json = ? WHERE id = 's1'")
.bind("{not json")
.execute(&pool)
.await
.unwrap();
assert_eq!(load_usage_snapshot(&pool, "s1").await, None);
}
#[tokio::test]
async fn usage_snapshot_keeps_optional_cost() {
let pool = test_pool().await;
let u = usage(1000, 200_000).cost(Cost::new(0.1234, "USD"));
persist_usage_snapshot(&pool, "s1", &u).await;
let loaded = load_usage_snapshot(&pool, "s1").await.expect("snapshot");
assert_eq!(loaded["cost"]["amount"], 0.1234);
assert_eq!(loaded["cost"]["currency"], "USD");
persist_usage_snapshot(&pool, "s1", &usage(1001, 200_000)).await;
let loaded = load_usage_snapshot(&pool, "s1").await.expect("snapshot");
assert!(loaded.get("cost").is_none());
}
}