1pub mod claude;
7pub mod codex;
8pub mod gemini;
9pub mod opencode;
10
11use crate::model::{Activity, Attribution, ContextOrigin, ContextSource, CostBreakdown, Harness, ProcNode, SpanKind, TokenUsage, ToolSpan};
12use crate::process::RawProc;
13use std::collections::{BTreeMap, HashSet, VecDeque};
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime};
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct ParseHealth {
27 pub billable_messages: u64,
29 pub usage_records: u64,
32 pub empty_usage_records: u64,
35}
36
37impl ParseHealth {
38 const MIN_EVIDENCE: u64 = 3;
41
42 pub fn fields_unrecognised(&self) -> bool {
49 self.billable_messages >= Self::MIN_EVIDENCE && self.usage_records == self.empty_usage_records
50 }
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct SessionSummary {
55 pub harness: Option<Harness>,
56 pub session_id: Option<String>,
57 pub cwd: Option<PathBuf>,
58 pub model: Option<String>,
59 pub harness_version: Option<String>,
60 pub usage: TokenUsage,
61 pub cost_usd: f64,
62 pub cost_breakdown: CostBreakdown,
64 pub unpriced_tokens: u64,
65 pub turns: u64,
66 pub subagent_turns: u64,
67 pub tool_calls: u64,
68 pub web_searches: u64,
70 pub spans: SpanLog,
71 pub mcp: BTreeMap<String, McpUsage>,
73 pub context: ContextLedger,
76 pub health: ParseHealth,
77 pub activity: Activity,
78 pub started_at: Option<SystemTime>,
79 pub last_activity: Option<SystemTime>,
80 pub rate_limit: Option<crate::model::RateLimit>,
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
87pub struct McpUsage {
88 pub calls: u64,
89 pub errors: u64,
90 pub last_call: Option<SystemTime>,
91}
92
93impl McpUsage {
94 pub fn add(&mut self, o: &McpUsage) {
95 self.calls += o.calls;
96 self.errors += o.errors;
97 self.last_call = self.last_call.max(o.last_call);
98 }
99}
100
101#[derive(Debug, Clone, Default)]
127pub struct ContextLedger {
128 shares: BTreeMap<ContextKey, ContextShare>,
129 live: BTreeMap<ContextKey, u64>,
131 pending: Vec<(String, ContextKey)>,
134 prev: Option<(u64, u64)>,
136}
137
138type ContextKey = (ContextOrigin, String);
139
140#[derive(Debug, Clone, Copy, Default, PartialEq)]
142pub struct ContextShare {
143 pub calls: u64,
144 pub tokens: u64,
145 pub cost_usd: f64,
146}
147
148impl ContextLedger {
149 pub const OTHER: &str = "other";
151
152 fn other() -> ContextKey {
153 (ContextOrigin::Other, Self::OTHER.to_string())
154 }
155
156 pub fn result(&mut self, id: &str, origin: ContextOrigin, name: &str) {
160 self.pending.push((id.to_string(), (origin, name.to_string())));
161 }
162
163 pub fn retag(&mut self, id: &str, origin: ContextOrigin, name: &str) {
166 if let Some((_, key)) = self.pending.iter_mut().find(|(i, _)| i == id) {
167 *key = (origin, name.to_string());
168 }
169 }
170
171 pub fn response(&mut self, usage: &TokenUsage, cost: &CostBreakdown) {
175 let prompt = usage.prompt();
176 if prompt == 0 {
177 return;
178 }
179 let pending = std::mem::take(&mut self.pending);
180 match self.prev {
181 Some((p, _)) if prompt < p / 2 => {
182 self.live.clear();
184 self.file(prompt, 0, pending);
185 }
186 Some((p, o)) if prompt >= p => {
187 let growth = prompt - p;
188 let reply = o.min(growth);
189 self.file(growth - reply, reply, pending);
190 }
191 Some(_) => {
192 let e = self.live.entry(Self::other()).or_default();
195 *e = e.saturating_sub(self.prev.map(|(p, _)| p - prompt).unwrap_or(0));
196 self.file(0, 0, pending);
197 }
198 None => self.file(prompt, 0, pending),
199 }
200 let prompt_cost = cost.input + cost.cache_read + cost.cache_write_5m + cost.cache_write_1h;
201 if prompt_cost > 0.0 {
202 let rate = prompt_cost / prompt as f64;
203 for (k, t) in &self.live {
204 self.shares.entry(k.clone()).or_default().cost_usd += *t as f64 * rate;
205 }
206 }
207 self.prev = Some((prompt, usage.output));
208 }
209
210 fn file(&mut self, results: u64, other: u64, pending: Vec<(String, ContextKey)>) {
213 if pending.is_empty() {
214 self.add(Self::other(), results + other, 0);
215 return;
216 }
217 self.add(Self::other(), other, 0);
218 let n = pending.len() as u64;
219 let (each, mut rem) = (results / n, results % n);
220 for (_, key) in pending {
221 let t = each + u64::from(rem > 0);
222 rem = rem.saturating_sub(1);
223 self.add(key, t, 1);
224 }
225 }
226
227 fn add(&mut self, key: ContextKey, tokens: u64, calls: u64) {
228 if tokens == 0 && calls == 0 {
229 return;
230 }
231 let sh = self.shares.entry(key.clone()).or_default();
232 sh.tokens += tokens;
233 sh.calls += calls;
234 *self.live.entry(key).or_default() += tokens;
235 }
236
237 pub fn compacted(&mut self) {
240 self.live.clear();
241 self.prev = None;
242 }
243
244 pub fn merge(&mut self, other: &ContextLedger) {
247 for (k, sh) in &other.shares {
248 let e = self.shares.entry(k.clone()).or_default();
249 e.calls += sh.calls;
250 e.tokens += sh.tokens;
251 e.cost_usd += sh.cost_usd;
252 }
253 }
254
255 pub fn is_empty(&self) -> bool {
256 self.shares.is_empty()
257 }
258
259 pub fn sources(&self) -> Vec<ContextSource> {
261 let mut v: Vec<ContextSource> = self
262 .shares
263 .iter()
264 .map(|((origin, name), sh)| ContextSource {
265 name: name.clone(),
266 origin: *origin,
267 calls: sh.calls,
268 tokens: sh.tokens,
269 cost_usd: sh.cost_usd,
270 })
271 .collect();
272 v.sort_by(|a, b| b.tokens.cmp(&a.tokens).then_with(|| a.name.cmp(&b.name)));
273 v
274 }
275}
276
277pub fn mcp_server_of(tool_name: &str) -> Option<&str> {
282 let rest = tool_name.strip_prefix("mcp__")?;
283 let server = match rest.rfind("__") {
284 Some(i) => &rest[..i],
285 None => rest,
286 };
287 if server.is_empty() { None } else { Some(server) }
288}
289
290pub const MAX_SPANS: usize = 256;
298
299#[derive(Debug, Clone)]
306pub struct SpanLog {
307 spans: VecDeque<ToolSpan>,
308 cap: usize,
309}
310
311impl Default for SpanLog {
312 fn default() -> Self {
313 SpanLog { spans: VecDeque::new(), cap: MAX_SPANS }
314 }
315}
316
317impl SpanLog {
318 pub fn unbounded() -> Self {
322 SpanLog { spans: VecDeque::new(), cap: usize::MAX }
323 }
324
325 pub fn open(&mut self, id: String, name: String, at: SystemTime, sidechain: bool) {
328 self.open_kind(id, name, at, sidechain, SpanKind::Tool);
329 }
330
331 pub fn open_kind(&mut self, id: String, name: String, at: SystemTime, sidechain: bool, kind: SpanKind) {
333 if id.is_empty() || self.spans.iter().any(|s| s.is_open() && s.id == id) {
334 return;
335 }
336 if self.spans.len() >= self.cap {
337 self.spans.pop_front();
338 }
339 self.spans.push_back(ToolSpan { id, name, started_at: at, duration_ms: None, sidechain, error: false, kind });
340 }
341
342 pub fn end_at(&mut self, id: &str, at: SystemTime) {
346 let Some(s) = self.spans.iter_mut().rev().find(|s| s.id == id) else { return };
347 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
348 }
349
350 pub fn open_of_kind(&self, kind: SpanKind) -> Option<&ToolSpan> {
352 self.spans.iter().rev().find(|s| s.is_open() && s.kind == kind)
353 }
354
355 pub fn discard_open(&mut self, id: &str) {
359 if let Some(i) = self.spans.iter().rposition(|s| s.is_open() && s.id == id) {
360 self.spans.remove(i);
361 }
362 }
363
364 pub fn close(&mut self, id: &str, at: SystemTime, error: bool) {
367 let Some(s) = self.spans.iter_mut().rev().find(|s| s.is_open() && s.id == id) else { return };
368 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
369 s.error = error;
370 }
371
372 pub fn len(&self) -> usize {
373 self.spans.len()
374 }
375
376 pub fn is_empty(&self) -> bool {
377 self.spans.is_empty()
378 }
379
380 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ToolSpan> + ExactSizeIterator {
383 self.spans.iter()
384 }
385
386 pub fn to_vec(&self) -> Vec<ToolSpan> {
387 self.spans.iter().cloned().collect()
388 }
389
390 pub fn merged<'a>(logs: impl IntoIterator<Item = &'a SpanLog>, cap: usize) -> SpanLog {
394 let mut spans: Vec<ToolSpan> = logs.into_iter().flat_map(|l| l.spans.iter().cloned()).collect();
395 spans.sort_by_key(|s| s.started_at);
396 if spans.len() > cap {
397 spans.drain(..spans.len() - cap);
398 }
399 SpanLog { spans: spans.into(), cap }
400 }
401
402 pub fn cap(&self) -> usize {
403 self.cap
404 }
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
409pub enum SpanRetention {
410 #[default]
412 Recent,
413 All,
417}
418
419impl SpanRetention {
420 pub(crate) fn log(self) -> SpanLog {
421 match self {
422 SpanRetention::Recent => SpanLog::default(),
423 SpanRetention::All => SpanLog::unbounded(),
424 }
425 }
426}
427
428pub trait SessionTracker {
429 fn refresh(&mut self) -> anyhow::Result<bool>;
432 fn summary(&self) -> &SessionSummary;
433 fn path(&self) -> &Path;
434
435 fn refresh_all(&mut self) -> anyhow::Result<()> {
439 while self.refresh()? {}
440 Ok(())
441 }
442}
443
444#[derive(Debug, Clone, Default, PartialEq, Eq)]
448pub struct RegistryHints {
449 pub name: Option<String>,
450 pub session_id: Option<String>,
451 pub cwd: Option<PathBuf>,
452 pub version: Option<String>,
453 pub status: Option<String>,
456}
457
458pub struct AttributeContext<'a> {
461 pub cwd: Option<&'a Path>,
462 pub proc_start: SystemTime,
463 pub now: SystemTime,
464 pub attached: &'a HashSet<PathBuf>,
467 pub activity_timeout: Duration,
470}
471
472pub trait HarnessAdapter {
479 fn harness(&self) -> Harness;
480
481 fn rescan(&mut self, since: SystemTime);
484
485 fn prepare(&mut self, _roots: &[&ProcNode]) {}
490
491 fn hints(&self, _pid: u32) -> Option<RegistryHints> {
493 None
494 }
495
496 fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution);
500
501 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf>;
503
504 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker>;
506
507 fn detect(&self, path: &Path) -> bool;
509
510 fn transcripts(&self) -> Vec<(String, PathBuf)>;
513}
514
515pub fn adapters() -> Vec<Box<dyn HarnessAdapter>> {
519 vec![
520 Box::new(codex::CodexAdapter::default()),
521 Box::new(gemini::GeminiAdapter::default()),
522 Box::new(opencode::OpenCodeAdapter::default()),
523 Box::new(claude::ClaudeAdapter::default()),
524 ]
525}
526
527pub fn adapter_for(harness: Harness) -> Option<Box<dyn HarnessAdapter>> {
529 adapters().into_iter().find(|a| a.harness() == harness)
530}
531
532pub fn detect(path: &Path) -> Option<Harness> {
535 adapters().iter().find(|a| a.detect(path)).map(|a| a.harness())
536}
537
538pub fn open_transcript(path: &Path, harness: Harness, spans: SpanRetention) -> Option<Box<dyn SessionTracker>> {
541 adapter_for(harness).map(|a| a.open(path, spans))
542}
543
544pub(crate) fn head_lines(path: &Path) -> Vec<serde_json::Value> {
546 use std::io::{BufRead, BufReader};
547 let Ok(f) = std::fs::File::open(path) else { return Vec::new() };
548 BufReader::new(f).lines().map_while(Result::ok).take(5).filter_map(|l| serde_json::from_str(&l).ok()).collect()
549}
550
551pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
554
555pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
561 let s = s.trim();
562 let (s, offset_secs) = match s.strip_suffix(['Z', 'z']) {
564 Some(rest) => (rest, 0i64),
565 None => {
566 let t_pos = s.find('T')?;
567 let sign_pos = s[t_pos..].rfind(['+', '-'])? + t_pos;
568 let (rest, zone) = s.split_at(sign_pos);
569 let sign = if zone.starts_with('-') { -1 } else { 1 };
570 let digits: String = zone[1..].chars().filter(|c| c.is_ascii_digit()).collect();
571 if digits.len() != 4 {
572 return None;
573 }
574 let oh = digits[..2].parse::<i64>().ok()?;
575 let om = digits[2..].parse::<i64>().ok()?;
576 (rest, sign * (oh * 3600 + om * 60))
577 }
578 };
579 let (date, time) = s.split_once('T')?;
580 let mut d = date.split('-');
581 let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
582 let mut t = time.split(':');
583 let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
584 let sec_str = t.next()?;
585 let (sec, frac) = match sec_str.split_once('.') {
586 Some((s, f)) => (s.parse::<u64>().ok()?, f),
587 None => (sec_str.parse::<u64>().ok()?, ""),
588 };
589 let nanos: u32 = if frac.is_empty() {
590 0
591 } else {
592 let mut f = frac.to_string();
593 f.truncate(9);
594 while f.len() < 9 {
595 f.push('0');
596 }
597 f.parse().ok()?
598 };
599 let days = days_from_civil(y, mo, da);
600 let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64 - offset_secs;
602 if secs < 0 {
603 return None;
604 }
605 Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
606}
607
608fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
610 let y = if m <= 2 { y - 1 } else { y };
611 let era = if y >= 0 { y } else { y - 399 } / 400;
612 let yoe = y - era * 400;
613 let mp = (m as i64 + 9) % 12;
614 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
615 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
616 era * 146_097 + doe - 719_468
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use std::time::Duration;
623
624 fn at(secs: u64) -> SystemTime {
625 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
626 }
627
628 #[test]
629 fn pairs_spans_by_id_out_of_order() {
630 let mut log = SpanLog::default();
631 log.open("a".into(), "Bash".into(), at(10), false);
632 log.open("b".into(), "Read".into(), at(11), true);
633 log.open("a".into(), "Bash".into(), at(10), false);
635 log.close("b", at(12), false);
637 log.close("a", at(14), true);
638 log.close("zzz", at(15), false);
640 let v = log.to_vec();
641 assert_eq!(v.len(), 2);
642 assert_eq!(v[0].name, "Bash");
643 assert_eq!(v[0].duration_ms, Some(4_000));
644 assert!(v[0].error);
645 assert_eq!(v[1].duration_ms, Some(1_000));
646 assert!(v[1].sidechain);
647 assert!(!v[1].error);
648 }
649
650 #[test]
651 fn keeps_the_newest_spans_and_reports_open_ones() {
652 let mut log = SpanLog::default();
653 for i in 0..(MAX_SPANS + 10) {
654 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
655 log.close(&format!("id{i}"), at(i as u64), false);
656 }
657 assert_eq!(log.len(), MAX_SPANS);
658 assert_eq!(log.iter().next().unwrap().id, "id10");
659 log.open("live".into(), "Bash".into(), at(500), false);
660 let last = log.to_vec().pop().unwrap();
661 assert!(last.is_open());
662 assert_eq!(last.elapsed_ms(at(503)), 3_000);
663 }
664
665 #[test]
666 fn end_at_moves_the_end_of_any_kind_of_span() {
667 let mut log = SpanLog::default();
668 log.open_kind("inference:1".into(), "inference".into(), at(10), false, SpanKind::Inference);
669 assert!(log.open_of_kind(SpanKind::Inference).is_some());
670 assert!(log.open_of_kind(SpanKind::Turn).is_none());
671 log.end_at("inference:1", at(11));
673 log.end_at("inference:1", at(13));
674 log.end_at("nope", at(99));
675 let v = log.to_vec();
676 assert_eq!(v[0].duration_ms, Some(3_000));
677 assert_eq!(v[0].kind, SpanKind::Inference);
678 assert!(log.open_of_kind(SpanKind::Inference).is_none());
679 log.discard_open("inference:1");
681 assert_eq!(log.len(), 1);
682 log.open_kind("inference:2".into(), "inference".into(), at(20), false, SpanKind::Inference);
683 log.discard_open("inference:2");
684 assert_eq!(log.len(), 1);
685 }
686
687 #[test]
688 fn unbounded_log_keeps_everything() {
689 let mut log = SpanLog::unbounded();
690 for i in 0..(MAX_SPANS * 3) {
691 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
692 log.close(&format!("id{i}"), at(i as u64 + 1), false);
693 }
694 assert_eq!(log.len(), MAX_SPANS * 3);
695 assert_eq!(log.iter().next().unwrap().id, "id0");
696 assert_eq!(SpanRetention::default(), SpanRetention::Recent);
697 }
698
699 #[test]
700 fn detects_the_harness_from_the_first_lines() {
701 let dir = std::env::temp_dir().join(format!("agent-top-detect-{}", std::process::id()));
702 std::fs::create_dir_all(&dir).unwrap();
703 let codex = dir.join("rollout.jsonl");
704 std::fs::write(&codex, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"x\"}}\n").unwrap();
705 let claude = dir.join("s.jsonl");
706 std::fs::write(&claude, "{\"type\":\"summary\",\"leafUuid\":\"u\"}\n{\"type\":\"user\",\"sessionId\":\"abc\"}\n").unwrap();
708 let other = dir.join("other.jsonl");
709 std::fs::write(&other, "{\"hello\":1}\nnot json\n").unwrap();
710 assert_eq!(detect(&codex), Some(Harness::Codex));
711 assert_eq!(detect(&claude), Some(Harness::Claude));
712 assert_eq!(detect(&other), None);
713 assert_eq!(detect(&dir.join("missing.jsonl")), None);
714 let _ = std::fs::remove_dir_all(&dir);
715 }
716
717 #[test]
718 fn names_the_server_behind_an_mcp_tool() {
719 assert_eq!(mcp_server_of("mcp__filesystem__read_file"), Some("filesystem"));
720 assert_eq!(mcp_server_of("mcp__chrome-devtools__take_screenshot"), Some("chrome-devtools"));
721 assert_eq!(mcp_server_of("mcp__claude_ai_Gmail__authenticate"), Some("claude_ai_Gmail"));
722 assert_eq!(mcp_server_of("mcp__odd"), Some("odd"));
723 assert_eq!(mcp_server_of("mcp____x"), None);
724 assert_eq!(mcp_server_of("Bash"), None);
725 }
726
727 #[test]
728 fn accuses_the_parser_only_with_enough_evidence() {
729 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
731 assert!(!h.fields_unrecognised());
732 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
734 assert!(!h.fields_unrecognised());
735 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
737 assert!(h.fields_unrecognised());
738 let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
741 assert!(h.fields_unrecognised());
742 let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
744 assert!(!h.fields_unrecognised());
745 assert!(!ParseHealth::default().fields_unrecognised());
747 }
748
749 fn usage(prompt: u64, output: u64) -> TokenUsage {
750 TokenUsage { cache_read: prompt, output, ..Default::default() }
751 }
752
753 fn cost(prompt: u64) -> CostBreakdown {
756 CostBreakdown { cache_read: prompt as f64 / 1e6, ..Default::default() }
757 }
758
759 fn share<'a>(v: &'a [ContextSource], name: &str) -> &'a ContextSource {
760 v.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("no source {name}"))
761 }
762
763 #[test]
764 fn context_ledger_files_prompt_growth_under_the_results_that_caused_it() {
765 let mut l = ContextLedger::default();
766 l.response(&usage(1_000, 100), &cost(1_000));
768 l.result("a", ContextOrigin::Tool, "Read");
771 l.result("b", ContextOrigin::Mcp, "fs");
772 l.response(&usage(3_300, 50), &cost(3_300));
773 let v = l.sources();
774 assert_eq!(share(&v, "Read").tokens, 1_100);
775 assert_eq!(share(&v, "fs").tokens, 1_100);
776 assert_eq!(share(&v, "fs").origin, ContextOrigin::Mcp);
777 assert_eq!(share(&v, "fs").calls, 1);
778 let other = share(&v, ContextLedger::OTHER);
779 assert_eq!((other.tokens, other.calls), (1_100, 0));
780 assert!((other.cost_usd - 2_100e-6).abs() < 1e-12, "{}", other.cost_usd);
782 assert!((share(&v, "Read").cost_usd - 1_100e-6).abs() < 1e-12);
783 let total: f64 = v.iter().map(|s| s.cost_usd).sum();
785 assert!((total - 4_300e-6).abs() < 1e-12, "{total}");
786 assert_eq!(v[0].tokens, 1_100, "largest first");
787 }
788
789 #[test]
790 fn context_ledger_takes_a_shrink_off_other_and_a_halving_as_compaction() {
791 let mut l = ContextLedger::default();
792 l.response(&usage(10_000, 2_000), &cost(10_000));
793 l.result("a", ContextOrigin::Tool, "Bash");
794 l.response(&usage(12_500, 3_000), &cost(12_500)); l.response(&usage(11_500, 10), &cost(11_500));
798 let v = l.sources();
799 assert_eq!(share(&v, "Bash").tokens, 500);
800 assert_eq!(share(&v, ContextLedger::OTHER).tokens, 12_000);
801 assert!((share(&v, "Bash").cost_usd - 1_000e-6).abs() < 1e-12);
803 l.response(&usage(3_000, 10), &cost(3_000));
806 let v = l.sources();
807 assert!((share(&v, "Bash").cost_usd - 1_000e-6).abs() < 1e-12, "not charged after compaction");
808 assert_eq!(share(&v, ContextLedger::OTHER).tokens, 15_000);
809 l.compacted();
811 l.response(&usage(4_000, 10), &cost(4_000));
812 let v = l.sources();
813 assert_eq!(share(&v, ContextLedger::OTHER).tokens, 19_000);
814 assert!((share(&v, "Bash").cost_usd - 1_000e-6).abs() < 1e-12);
815 }
816
817 #[test]
818 fn context_ledger_retags_pending_results_and_merges() {
819 let mut l = ContextLedger::default();
820 l.response(&usage(100, 0), &cost(100));
821 l.result("c1", ContextOrigin::Tool, "fetch");
822 l.retag("c1", ContextOrigin::Mcp, "apps");
823 l.retag("zzz", ContextOrigin::Mcp, "nope");
824 l.response(&TokenUsage::default(), &CostBreakdown::default());
826 l.response(&usage(300, 0), &cost(300));
827 let v = l.sources();
828 assert_eq!(v.len(), 2);
829 assert_eq!((share(&v, "apps").origin, share(&v, "apps").tokens), (ContextOrigin::Mcp, 200));
830 assert!(v.iter().all(|s| s.name != "fetch"));
831
832 let mut sub = ContextLedger::default();
833 sub.response(&usage(50, 0), &cost(50));
834 sub.result("x", ContextOrigin::Mcp, "apps");
835 sub.response(&usage(70, 0), &cost(70));
836 l.merge(&sub);
837 let v = l.sources();
838 assert_eq!(share(&v, "apps").tokens, 220);
839 assert_eq!(share(&v, "apps").calls, 2);
840 assert_eq!(share(&v, ContextLedger::OTHER).tokens, 150);
841 assert!(!l.is_empty() && ContextLedger::default().is_empty());
842 }
843
844 #[test]
845 fn parses_timestamps() {
846 let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
847 assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
848 let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
849 let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
850 assert_eq!(secs.as_secs(), 1_788_419_734);
851 assert_eq!(secs.subsec_millis(), 500);
852 assert!(parse_rfc3339_utc("nope").is_none());
853 }
854}
855
856#[cfg(test)]
857mod rfc3339_tests {
858 use super::parse_rfc3339_utc;
859 use std::time::{Duration, UNIX_EPOCH};
860
861 fn secs(s: &str) -> u64 {
862 parse_rfc3339_utc(s).unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs()
863 }
864
865 #[test]
868 fn offsets_are_folded_into_utc() {
869 let z = secs("2026-09-03T07:15:34Z");
870 assert_eq!(secs("2026-09-03T08:15:34+01:00"), z);
871 assert_eq!(secs("2026-09-03T00:15:34-07:00"), z);
872 assert_eq!(secs("2026-09-03T08:15:34+0100"), z, "no colon");
873 assert_eq!(secs("2026-09-03T07:15:34+00:00"), z);
874 assert_eq!(secs("2026-09-03T12:45:34+05:30"), z, "half-hour zone");
875 let ms = parse_rfc3339_utc("2026-09-03T08:15:34.250+01:00").unwrap();
876 assert_eq!(ms, UNIX_EPOCH + Duration::new(z, 250_000_000));
877 assert_eq!(secs("2026-09-03T07:15:34.5Z"), z);
879 assert!(parse_rfc3339_utc("2026-09-03T07:15:34").is_none(), "no zone at all");
881 assert!(parse_rfc3339_utc("2026-09-03T07:15:34+1").is_none());
882 assert!(parse_rfc3339_utc("garbage").is_none());
883 }
884}