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> {
559 let s = s.strip_suffix('Z')?;
560 let (date, time) = s.split_once('T')?;
561 let mut d = date.split('-');
562 let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
563 let mut t = time.split(':');
564 let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
565 let sec_str = t.next()?;
566 let (sec, frac) = match sec_str.split_once('.') {
567 Some((s, f)) => (s.parse::<u64>().ok()?, f),
568 None => (sec_str.parse::<u64>().ok()?, ""),
569 };
570 let nanos: u32 = if frac.is_empty() {
571 0
572 } else {
573 let mut f = frac.to_string();
574 f.truncate(9);
575 while f.len() < 9 {
576 f.push('0');
577 }
578 f.parse().ok()?
579 };
580 let days = days_from_civil(y, mo, da);
581 let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64;
582 if secs < 0 {
583 return None;
584 }
585 Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
586}
587
588fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
590 let y = if m <= 2 { y - 1 } else { y };
591 let era = if y >= 0 { y } else { y - 399 } / 400;
592 let yoe = y - era * 400;
593 let mp = (m as i64 + 9) % 12;
594 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
595 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
596 era * 146_097 + doe - 719_468
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use std::time::Duration;
603
604 fn at(secs: u64) -> SystemTime {
605 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
606 }
607
608 #[test]
609 fn pairs_spans_by_id_out_of_order() {
610 let mut log = SpanLog::default();
611 log.open("a".into(), "Bash".into(), at(10), false);
612 log.open("b".into(), "Read".into(), at(11), true);
613 log.open("a".into(), "Bash".into(), at(10), false);
615 log.close("b", at(12), false);
617 log.close("a", at(14), true);
618 log.close("zzz", at(15), false);
620 let v = log.to_vec();
621 assert_eq!(v.len(), 2);
622 assert_eq!(v[0].name, "Bash");
623 assert_eq!(v[0].duration_ms, Some(4_000));
624 assert!(v[0].error);
625 assert_eq!(v[1].duration_ms, Some(1_000));
626 assert!(v[1].sidechain);
627 assert!(!v[1].error);
628 }
629
630 #[test]
631 fn keeps_the_newest_spans_and_reports_open_ones() {
632 let mut log = SpanLog::default();
633 for i in 0..(MAX_SPANS + 10) {
634 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
635 log.close(&format!("id{i}"), at(i as u64), false);
636 }
637 assert_eq!(log.len(), MAX_SPANS);
638 assert_eq!(log.iter().next().unwrap().id, "id10");
639 log.open("live".into(), "Bash".into(), at(500), false);
640 let last = log.to_vec().pop().unwrap();
641 assert!(last.is_open());
642 assert_eq!(last.elapsed_ms(at(503)), 3_000);
643 }
644
645 #[test]
646 fn end_at_moves_the_end_of_any_kind_of_span() {
647 let mut log = SpanLog::default();
648 log.open_kind("inference:1".into(), "inference".into(), at(10), false, SpanKind::Inference);
649 assert!(log.open_of_kind(SpanKind::Inference).is_some());
650 assert!(log.open_of_kind(SpanKind::Turn).is_none());
651 log.end_at("inference:1", at(11));
653 log.end_at("inference:1", at(13));
654 log.end_at("nope", at(99));
655 let v = log.to_vec();
656 assert_eq!(v[0].duration_ms, Some(3_000));
657 assert_eq!(v[0].kind, SpanKind::Inference);
658 assert!(log.open_of_kind(SpanKind::Inference).is_none());
659 log.discard_open("inference:1");
661 assert_eq!(log.len(), 1);
662 log.open_kind("inference:2".into(), "inference".into(), at(20), false, SpanKind::Inference);
663 log.discard_open("inference:2");
664 assert_eq!(log.len(), 1);
665 }
666
667 #[test]
668 fn unbounded_log_keeps_everything() {
669 let mut log = SpanLog::unbounded();
670 for i in 0..(MAX_SPANS * 3) {
671 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
672 log.close(&format!("id{i}"), at(i as u64 + 1), false);
673 }
674 assert_eq!(log.len(), MAX_SPANS * 3);
675 assert_eq!(log.iter().next().unwrap().id, "id0");
676 assert_eq!(SpanRetention::default(), SpanRetention::Recent);
677 }
678
679 #[test]
680 fn detects_the_harness_from_the_first_lines() {
681 let dir = std::env::temp_dir().join(format!("agent-top-detect-{}", std::process::id()));
682 std::fs::create_dir_all(&dir).unwrap();
683 let codex = dir.join("rollout.jsonl");
684 std::fs::write(&codex, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"x\"}}\n").unwrap();
685 let claude = dir.join("s.jsonl");
686 std::fs::write(&claude, "{\"type\":\"summary\",\"leafUuid\":\"u\"}\n{\"type\":\"user\",\"sessionId\":\"abc\"}\n").unwrap();
688 let other = dir.join("other.jsonl");
689 std::fs::write(&other, "{\"hello\":1}\nnot json\n").unwrap();
690 assert_eq!(detect(&codex), Some(Harness::Codex));
691 assert_eq!(detect(&claude), Some(Harness::Claude));
692 assert_eq!(detect(&other), None);
693 assert_eq!(detect(&dir.join("missing.jsonl")), None);
694 let _ = std::fs::remove_dir_all(&dir);
695 }
696
697 #[test]
698 fn names_the_server_behind_an_mcp_tool() {
699 assert_eq!(mcp_server_of("mcp__filesystem__read_file"), Some("filesystem"));
700 assert_eq!(mcp_server_of("mcp__chrome-devtools__take_screenshot"), Some("chrome-devtools"));
701 assert_eq!(mcp_server_of("mcp__claude_ai_Gmail__authenticate"), Some("claude_ai_Gmail"));
702 assert_eq!(mcp_server_of("mcp__odd"), Some("odd"));
703 assert_eq!(mcp_server_of("mcp____x"), None);
704 assert_eq!(mcp_server_of("Bash"), None);
705 }
706
707 #[test]
708 fn accuses_the_parser_only_with_enough_evidence() {
709 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
711 assert!(!h.fields_unrecognised());
712 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
714 assert!(!h.fields_unrecognised());
715 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
717 assert!(h.fields_unrecognised());
718 let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
721 assert!(h.fields_unrecognised());
722 let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
724 assert!(!h.fields_unrecognised());
725 assert!(!ParseHealth::default().fields_unrecognised());
727 }
728
729 fn usage(prompt: u64, output: u64) -> TokenUsage {
730 TokenUsage { cache_read: prompt, output, ..Default::default() }
731 }
732
733 fn cost(prompt: u64) -> CostBreakdown {
736 CostBreakdown { cache_read: prompt as f64 / 1e6, ..Default::default() }
737 }
738
739 fn share<'a>(v: &'a [ContextSource], name: &str) -> &'a ContextSource {
740 v.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("no source {name}"))
741 }
742
743 #[test]
744 fn context_ledger_files_prompt_growth_under_the_results_that_caused_it() {
745 let mut l = ContextLedger::default();
746 l.response(&usage(1_000, 100), &cost(1_000));
748 l.result("a", ContextOrigin::Tool, "Read");
751 l.result("b", ContextOrigin::Mcp, "fs");
752 l.response(&usage(3_300, 50), &cost(3_300));
753 let v = l.sources();
754 assert_eq!(share(&v, "Read").tokens, 1_100);
755 assert_eq!(share(&v, "fs").tokens, 1_100);
756 assert_eq!(share(&v, "fs").origin, ContextOrigin::Mcp);
757 assert_eq!(share(&v, "fs").calls, 1);
758 let other = share(&v, ContextLedger::OTHER);
759 assert_eq!((other.tokens, other.calls), (1_100, 0));
760 assert!((other.cost_usd - 2_100e-6).abs() < 1e-12, "{}", other.cost_usd);
762 assert!((share(&v, "Read").cost_usd - 1_100e-6).abs() < 1e-12);
763 let total: f64 = v.iter().map(|s| s.cost_usd).sum();
765 assert!((total - 4_300e-6).abs() < 1e-12, "{total}");
766 assert_eq!(v[0].tokens, 1_100, "largest first");
767 }
768
769 #[test]
770 fn context_ledger_takes_a_shrink_off_other_and_a_halving_as_compaction() {
771 let mut l = ContextLedger::default();
772 l.response(&usage(10_000, 2_000), &cost(10_000));
773 l.result("a", ContextOrigin::Tool, "Bash");
774 l.response(&usage(12_500, 3_000), &cost(12_500)); l.response(&usage(11_500, 10), &cost(11_500));
778 let v = l.sources();
779 assert_eq!(share(&v, "Bash").tokens, 500);
780 assert_eq!(share(&v, ContextLedger::OTHER).tokens, 12_000);
781 assert!((share(&v, "Bash").cost_usd - 1_000e-6).abs() < 1e-12);
783 l.response(&usage(3_000, 10), &cost(3_000));
786 let v = l.sources();
787 assert!((share(&v, "Bash").cost_usd - 1_000e-6).abs() < 1e-12, "not charged after compaction");
788 assert_eq!(share(&v, ContextLedger::OTHER).tokens, 15_000);
789 l.compacted();
791 l.response(&usage(4_000, 10), &cost(4_000));
792 let v = l.sources();
793 assert_eq!(share(&v, ContextLedger::OTHER).tokens, 19_000);
794 assert!((share(&v, "Bash").cost_usd - 1_000e-6).abs() < 1e-12);
795 }
796
797 #[test]
798 fn context_ledger_retags_pending_results_and_merges() {
799 let mut l = ContextLedger::default();
800 l.response(&usage(100, 0), &cost(100));
801 l.result("c1", ContextOrigin::Tool, "fetch");
802 l.retag("c1", ContextOrigin::Mcp, "apps");
803 l.retag("zzz", ContextOrigin::Mcp, "nope");
804 l.response(&TokenUsage::default(), &CostBreakdown::default());
806 l.response(&usage(300, 0), &cost(300));
807 let v = l.sources();
808 assert_eq!(v.len(), 2);
809 assert_eq!((share(&v, "apps").origin, share(&v, "apps").tokens), (ContextOrigin::Mcp, 200));
810 assert!(v.iter().all(|s| s.name != "fetch"));
811
812 let mut sub = ContextLedger::default();
813 sub.response(&usage(50, 0), &cost(50));
814 sub.result("x", ContextOrigin::Mcp, "apps");
815 sub.response(&usage(70, 0), &cost(70));
816 l.merge(&sub);
817 let v = l.sources();
818 assert_eq!(share(&v, "apps").tokens, 220);
819 assert_eq!(share(&v, "apps").calls, 2);
820 assert_eq!(share(&v, ContextLedger::OTHER).tokens, 150);
821 assert!(!l.is_empty() && ContextLedger::default().is_empty());
822 }
823
824 #[test]
825 fn parses_timestamps() {
826 let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
827 assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
828 let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
829 let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
830 assert_eq!(secs.as_secs(), 1_788_419_734);
831 assert_eq!(secs.subsec_millis(), 500);
832 assert!(parse_rfc3339_utc("nope").is_none());
833 }
834}