use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActivityState {
Working,
Idle,
BlockedOnPermission,
Errored,
Done,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityVerdict {
pub state: ActivityState,
pub summary: String,
pub confidence: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckMetrics {
pub session_id: String,
pub at: DateTime<Utc>,
pub model: String,
pub input_tokens: u32,
pub output_tokens: u32,
pub latency_ms: u64,
pub cache_hit: bool,
pub verdict_state: ActivityState,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CostTally {
pub total_checks: u64,
pub llm_calls_made: u64,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
}
#[derive(Debug)]
pub struct ActivityCache {
pub last_hash: Option<String>,
pub last_verdict: Option<ActivityVerdict>,
pub tally: CostTally,
pub model: String,
}
impl ActivityCache {
pub fn new(model: &str) -> Self {
Self {
last_hash: None,
last_verdict: None,
tally: CostTally::default(),
model: model.to_owned(),
}
}
pub fn check_unchanged(&self, hash: &str) -> bool {
self.last_hash.as_deref() == Some(hash)
}
pub fn update_cache_hit(&mut self, hash: &str, _metrics: CheckMetrics) {
self.last_hash = Some(hash.to_owned());
self.tally.total_checks += 1;
}
pub fn update_llm_hit(&mut self, hash: &str, verdict: ActivityVerdict, metrics: CheckMetrics) {
self.last_hash = Some(hash.to_owned());
self.last_verdict = Some(verdict);
self.tally.total_checks += 1;
self.tally.llm_calls_made += 1;
self.tally.total_input_tokens += u64::from(metrics.input_tokens);
self.tally.total_output_tokens += u64::from(metrics.output_tokens);
}
pub fn last_verdict(&self) -> Option<&ActivityVerdict> {
self.last_verdict.as_ref()
}
pub fn tally(&self) -> &CostTally {
&self.tally
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_metrics(cache_hit: bool) -> CheckMetrics {
CheckMetrics {
session_id: "s1".into(),
at: Utc::now(),
model: "openai/gpt-4o-mini".into(),
input_tokens: 100,
output_tokens: 20,
latency_ms: 200,
cache_hit,
verdict_state: ActivityState::Working,
}
}
fn working_verdict() -> ActivityVerdict {
ActivityVerdict {
state: ActivityState::Working,
summary: "Session is writing code".into(),
confidence: 0.9,
}
}
#[test]
fn cache_hit_on_same_hash() {
let mut cache = ActivityCache::new("gpt-4o-mini");
let hash = "abc123";
let verdict = working_verdict();
cache.update_llm_hit(hash, verdict.clone(), fake_metrics(false));
assert!(cache.check_unchanged(hash));
assert_eq!(cache.last_verdict().unwrap().summary, verdict.summary);
}
#[test]
fn cache_miss_on_new_hash() {
let mut cache = ActivityCache::new("gpt-4o-mini");
cache.update_llm_hit("old_hash", working_verdict(), fake_metrics(false));
assert!(!cache.check_unchanged("new_hash"));
}
#[test]
fn tally_accumulates_llm_calls() {
let mut cache = ActivityCache::new("gpt-4o-mini");
cache.update_llm_hit("h1", working_verdict(), fake_metrics(false));
cache.update_llm_hit("h2", working_verdict(), fake_metrics(false));
assert_eq!(cache.tally().total_checks, 2);
assert_eq!(cache.tally().llm_calls_made, 2);
assert_eq!(cache.tally().total_input_tokens, 200);
assert_eq!(cache.tally().total_output_tokens, 40);
}
#[test]
fn tally_accumulates_cache_hits() {
let mut cache = ActivityCache::new("gpt-4o-mini");
cache.update_llm_hit("h1", working_verdict(), fake_metrics(false));
cache.update_cache_hit("h1", fake_metrics(true));
cache.update_cache_hit("h1", fake_metrics(true));
assert_eq!(cache.tally().total_checks, 3);
assert_eq!(cache.tally().llm_calls_made, 1);
}
#[test]
fn activity_state_round_trip() {
let states = [
ActivityState::Working,
ActivityState::Idle,
ActivityState::BlockedOnPermission,
ActivityState::Errored,
ActivityState::Done,
ActivityState::Unknown,
];
for state in &states {
let json = serde_json::to_string(state).expect("serialize");
let back: ActivityState = serde_json::from_str(&json).expect("deserialize");
assert_eq!(&back, state);
}
}
#[test]
fn verdict_serde_round_trip() {
let v = working_verdict();
let json = serde_json::to_string(&v).expect("serialize");
let back: ActivityVerdict = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.summary, v.summary);
assert_eq!(back.state, v.state);
}
}