Skip to main content

agent_top_core/harness/
mod.rs

1//! Per-harness transcript readers.
2//!
3//! Each harness writes a different append-only log. A `SessionTracker` turns
4//! one of those logs into the harness-neutral `SessionSummary` incrementally.
5
6pub 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/// Evidence that the parser still understands the file it is reading.
18///
19/// Every field is read with a fallback to zero, which is the right behaviour
20/// for a genuinely absent field and the wrong behaviour for a renamed one: a
21/// harness that renames `usage` next week would show a user 0 tokens and $0.00
22/// with no error at all. So count the usage records seen and how many of them
23/// yielded nothing. Records present and all of them empty is not a quiet
24/// session, it is a parser that has fallen behind the format.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct ParseHealth {
27    /// Model responses seen. Each of these should account for some tokens.
28    pub billable_messages: u64,
29    /// Usage records found on them. Zero of these, with messages present, means
30    /// the record itself moved or was renamed.
31    pub usage_records: u64,
32    /// Records found but yielding nothing, which is what a renamed field inside
33    /// an intact record looks like.
34    pub empty_usage_records: u64,
35}
36
37impl ParseHealth {
38    /// Enough responses to accuse the parser rather than the session. A couple
39    /// of odd messages must not raise the alarm.
40    const MIN_EVIDENCE: u64 = 3;
41
42    /// The session did work that must have cost tokens, and we read none.
43    ///
44    /// Covers both ways a format change reaches us: the usage record moving or
45    /// being renamed, so we never find one, and the fields inside it being
46    /// renamed, so we find records that read as empty. Neither raises an error
47    /// on its own, because every field falls back to zero.
48    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    /// `cost_usd` by kind of token. See `Agent::cost_breakdown`.
63    pub cost_breakdown: CostBreakdown,
64    pub unpriced_tokens: u64,
65    pub turns: u64,
66    pub subagent_turns: u64,
67    pub tool_calls: u64,
68    /// See `Agent::web_searches`.
69    pub web_searches: u64,
70    pub spans: SpanLog,
71    /// Calls to each MCP server, by the server's name.
72    pub mcp: BTreeMap<String, McpUsage>,
73    /// What each tool's results added to the prompt, and what carrying it
74    /// has cost. See `ContextLedger`.
75    pub context: ContextLedger,
76    pub health: ParseHealth,
77    pub activity: Activity,
78    pub started_at: Option<SystemTime>,
79    pub last_activity: Option<SystemTime>,
80    /// How close the session is to its rate limit, when the harness writes it.
81    pub rate_limit: Option<crate::model::RateLimit>,
82}
83
84/// What a transcript says about one MCP server: how often it was called,
85/// how often that failed, and when it was last called.
86#[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/// What each source has added to a session's context, and what carrying it
102/// has cost. Built incrementally by an adapter from two facts it already
103/// has: the tool results it sees submitted, and the usage on each response.
104///
105/// The arithmetic. A response's prompt is the previous response's prompt
106/// plus everything appended since: the previous reply, and the tool results
107/// that answered it. So `prompt_n - prompt_n-1` is the new material, the
108/// previous reply's `output` is the part of it the model wrote itself, and
109/// the rest is the tool results submitted in between. Those tokens go to
110/// the tools that produced them, split evenly when several were answered
111/// together, which is a heuristic and is labelled as one in the UI. The
112/// first response's whole prompt, the replies, and any growth with no
113/// result to explain it (the user's own messages) are `Other`.
114///
115/// Every response then re-reads the whole context, so each source's live
116/// tokens are charged at that response's prompt rate: its prompt-side cost
117/// over its prompt tokens, which is mostly the cache-read price with some
118/// fresh input mixed in. The first read is charged the same way, so the
119/// sources' costs sum to the session's prompt-side cost.
120///
121/// A compaction replaces the context. The live set is cleared when the
122/// harness says one happened (`compacted`), and, for a harness that does
123/// not say, when the prompt halves, which nothing else does. Thinking
124/// blocks a harness drops between turns shrink the prompt by less than
125/// that; the shrink is taken off `Other`, whose replies they were.
126#[derive(Debug, Clone, Default)]
127pub struct ContextLedger {
128    shares: BTreeMap<ContextKey, ContextShare>,
129    /// Tokens each source has in the context now; cleared at a compaction.
130    live: BTreeMap<ContextKey, u64>,
131    /// Results submitted since the last response, by call id, awaiting the
132    /// response that will say how big they were.
133    pending: Vec<(String, ContextKey)>,
134    /// The last response's prompt and output, for the next delta.
135    prev: Option<(u64, u64)>,
136}
137
138type ContextKey = (ContextOrigin, String);
139
140/// One source's running totals.
141#[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    /// The name every non-tool share is filed under.
150    pub const OTHER: &str = "other";
151
152    fn other() -> ContextKey {
153        (ContextOrigin::Other, Self::OTHER.to_string())
154    }
155
156    /// A tool result was submitted to the model. `id` is the harness's call
157    /// id, so a harness that names the MCP server only after the result is
158    /// written can `retag` it before the response arrives.
159    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    /// Re-file a pending result under another source. A no-op once the
164    /// response that sized it has been seen.
165    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    /// A response came back. Sizes and files whatever was submitted since
172    /// the last one, then charges everything in the context for this read.
173    /// A record with no prompt is not an API response and is ignored.
174    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                // Compacted: what is in the context now is all new.
183                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                // Shrunk, but not compacted: thinking blocks dropped between
193                // turns. They were part of a reply, so the shrink is Other's.
194                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    /// `results` tokens across the pending results, evenly; `other` tokens
211    /// to `Other`. No pending result puts everything under `Other`.
212    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    /// The harness replaced the context with a summary. Whatever the next
238    /// response carries is new.
239    pub fn compacted(&mut self) {
240        self.live.clear();
241        self.prev = None;
242    }
243
244    /// Fold another ledger's totals in, as a parent folds its subagents.
245    /// The running state is not merged: a fold is read, never fed.
246    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    /// Every source, largest first.
260    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
277/// The server behind an MCP tool name. Claude Code names them
278/// `mcp__<server>__<tool>`; the server part may itself contain underscores,
279/// so the split is on the first double underscore after the prefix and the
280/// tool part is whatever follows the last one.
281pub 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
290/// Spans kept per session by the live tracker. A screenful of waterfall is
291/// a few dozen rows; the rest is history nobody scrolls to in a live view, and
292/// every span costs a clone on each refresh. An export wants the whole session
293/// and uses `SpanLog::unbounded` in a separate pass; see `SpanRetention`.
294///
295/// Sized for roughly a hundred tool calls: each model response also adds an
296/// inference span and each human prompt a turn span.
297pub const MAX_SPANS: usize = 256;
298
299/// A bounded, in-order log of tool spans, built by pairing a harness's
300/// "call started" and "call finished" records by call id.
301///
302/// Records arrive interleaved and out of order (agents run tools in parallel),
303/// so a span is closed by searching back for the still-open span with that id
304/// rather than assuming the most recent one.
305#[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    /// A log that keeps every span. For a one-shot pass over a whole
319    /// transcript, never for the live tracker, where the memory and the clone
320    /// per refresh would grow with the session.
321    pub fn unbounded() -> Self {
322        SpanLog { spans: VecDeque::new(), cap: usize::MAX }
323    }
324
325    /// Record the start of a tool call. Ignored when that id is already open,
326    /// so a transcript line replayed by the harness does not double-count.
327    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    /// `open`, for any kind of span.
332    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    /// Move the end of the newest span with this id to `at`, open or not. An
343    /// inference span grows as the response streams in, one content block
344    /// per line, and its end is wherever the last block landed.
345    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    /// The newest open span of this kind, if any.
351    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    /// Remove the newest span with this id if it is still open. For a span
356    /// that turned out not to be one: an inference that never produced a
357    /// reply because the user interrupted or submitted again.
358    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    /// Close the open call with this id. A result whose call scrolled out of
365    /// the window, or that we never saw start, is dropped.
366    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    /// Oldest first. Double-ended so callers can take the newest spans without
381    /// collecting the whole log first, which the UI and the golden tests both do.
382    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    /// One log from several, ordered by start time, keeping the newest `cap`.
391    /// A parent's spans and its subagents' spans interleave in wall-clock
392    /// order, which is what a waterfall wants.
393    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/// How many of a session's tool spans a tracker keeps.
408#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
409pub enum SpanRetention {
410    /// The newest `MAX_SPANS`, enough for the live waterfall. The default.
411    #[default]
412    Recent,
413    /// Every span in the transcript, for a trace export. Memory grows with
414    /// the session, so this is for a single pass, not a tracker kept across
415    /// refreshes.
416    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    /// Ingest whatever was appended since the last call. Returns true when
430    /// there is still unread data (the byte budget was exhausted).
431    fn refresh(&mut self) -> anyhow::Result<bool>;
432    fn summary(&self) -> &SessionSummary;
433    fn path(&self) -> &Path;
434
435    /// Ingest the whole file, however many refreshes that takes. For a
436    /// one-shot read such as an export; the live collector spreads a large
437    /// transcript over several ticks instead.
438    fn refresh_all(&mut self) -> anyhow::Result<()> {
439        while self.refresh()? {}
440        Ok(())
441    }
442}
443
444/// What a harness's own registry says about one of its processes, when it
445/// keeps one (Claude Code's `~/.claude/sessions/<pid>.json`). Every field is
446/// optional; a harness with no registry returns none of this.
447#[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    /// The harness's own word for its state (`busy`, `idle`, ...), which beats
454    /// any transcript heuristic.
455    pub status: Option<String>,
456}
457
458/// What the collector knows about a process when it asks an adapter which
459/// transcript is the process's.
460pub struct AttributeContext<'a> {
461    pub cwd: Option<&'a Path>,
462    pub proc_start: SystemTime,
463    pub now: SystemTime,
464    /// Transcripts already given to another process this pass. An adapter
465    /// must not hand one out twice.
466    pub attached: &'a HashSet<PathBuf>,
467    /// A transcript idle for longer than this is a finished conversation, not
468    /// a thread of a process that cannot otherwise be matched.
469    pub activity_timeout: Duration,
470}
471
472/// One harness, as the collector sees it: where its transcripts are, which
473/// belongs to which process, and how to read one. The collector holds a list
474/// of these and never names a harness itself, so adding a harness is one
475/// module and one line in `adapters()`. Process recognition stays in
476/// `process::classify_agent`, which also knows the harnesses that have no
477/// transcript adapter yet.
478pub trait HarnessAdapter {
479    fn harness(&self) -> Harness;
480
481    /// Re-list the transcripts written since `since`. Called every
482    /// `fs_scan_interval`, not every tick.
483    fn rescan(&mut self, since: SystemTime);
484
485    /// Called once per pass with this harness's root processes, before any
486    /// of them is attributed. For work that must see every process at once:
487    /// Codex reads which rollouts each process holds open here, so that no
488    /// process's fallback can claim a thread another is demonstrably writing.
489    fn prepare(&mut self, _roots: &[&ProcNode]) {}
490
491    /// The harness's own registry entry for a process, if it keeps one.
492    fn hints(&self, _pid: u32) -> Option<RegistryHints> {
493        None
494    }
495
496    /// The transcripts this process is writing, newest activity first, and
497    /// how sure the adapter is. One per conversation: a Codex app-server hosts
498    /// many, a CLI runs one, a process with none gets an empty list.
499    fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution);
500
501    /// Recently written transcripts no process owns: the stopped list.
502    fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf>;
503
504    /// A tracker for one transcript.
505    fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker>;
506
507    /// Whether this harness wrote the file, judged from its first few lines.
508    fn detect(&self, path: &Path) -> bool;
509
510    /// Every transcript on disk, however old, with the id a user would type
511    /// to name it. For `agent-top trace --session <id>`.
512    fn transcripts(&self) -> Vec<(String, PathBuf)>;
513}
514
515/// Every harness that has a transcript adapter, in the order they are asked.
516/// The order matters to `detect` alone: Gemini's metadata line carries a
517/// `sessionId` like Claude Code's lines do, so it is asked first.
518pub 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
527/// The adapter for one harness, or none when it has only a process table entry.
528pub fn adapter_for(harness: Harness) -> Option<Box<dyn HarnessAdapter>> {
529    adapters().into_iter().find(|a| a.harness() == harness)
530}
531
532/// Which harness wrote a transcript, judged from its first few lines. Anything
533/// no adapter recognises is not a transcript agent-top reads.
534pub fn detect(path: &Path) -> Option<Harness> {
535    adapters().iter().find(|a| a.detect(path)).map(|a| a.harness())
536}
537
538/// A tracker for a transcript whose harness is already known, or none when
539/// that harness has no transcript adapter.
540pub 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
544/// The first few lines of a file, parsed, for `HarnessAdapter::detect`.
545pub(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
551/// Bytes ingested per tracker per refresh. Keeps a cold start on a 100 MB
552/// transcript from freezing the first frame; the rest streams in on later ticks.
553pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
554
555/// Parse an RFC 3339 timestamp like `2026-09-03T07:15:34.123Z` into SystemTime
556/// without pulling in a date crate. The UTC `Z` form is what Claude Code,
557/// Codex and Gemini write; a numeric offset (`+01:00`, `-0700`) is accepted
558/// too, since a harness that writes local time would otherwise lose its
559/// last-activity time and with it the idle clock and the mtime fallbacks.
560pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
561    let s = s.trim();
562    // Split off the zone: `Z`, or a signed offset after the time.
563    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    // Local time minus its offset is UTC.
601    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
608// Howard Hinnant's days-from-civil algorithm.
609fn 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        // Replay of the same start line must not open a second span.
634        log.open("a".into(), "Bash".into(), at(10), false);
635        // Results come back in the other order.
636        log.close("b", at(12), false);
637        log.close("a", at(14), true);
638        // A result with no matching call is ignored.
639        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        // The response streams in over three lines; the span ends at the last one.
672        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        // Discarding only removes open spans; the ended one stays.
680        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        // A summary line first, as Claude Code writes on resume, then a real one.
707        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        // Healthy: records found on the messages, tokens read from them.
730        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
731        assert!(!h.fields_unrecognised());
732        // One odd message among many is a message, not a format change.
733        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
734        assert!(!h.fields_unrecognised());
735        // Fields inside the record renamed: records found, all of them empty.
736        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
737        assert!(h.fields_unrecognised());
738        // The record itself renamed or moved: messages, but no records at all.
739        // This is the case a naive check misses, because there is nothing to count.
740        let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
741        assert!(h.fields_unrecognised());
742        // Too early to tell: a session that has barely started.
743        let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
744        assert!(!h.fields_unrecognised());
745        // Nothing parsed at all is silence, not evidence.
746        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    /// $1 per million prompt tokens, so a source's cost is its live tokens
754    /// summed over the responses that read them, in micro-dollars.
755    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        // First response: the whole prompt is the system prompt and the ask.
767        l.response(&usage(1_000, 100), &cost(1_000));
768        // Two tool results, then a response 2_300 bigger: 100 of that is the
769        // reply, 2_200 the results, split evenly.
770        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        // Costs: Other was read twice (1_000 then 1_100), the results once.
781        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        // The sources sum to the prompt-side cost.
784        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)); // Bash gets 500
795        // Thinking dropped at the new turn: 1_000 smaller. Bash keeps its 500;
796        // Other's live share takes the shrink and no source grows.
797        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        // Bash was read by the two responses since it arrived: 500 * 2.
802        assert!((share(&v, "Bash").cost_usd - 1_000e-6).abs() < 1e-12);
803        // Compaction halves the prompt: the old context is gone, the summary
804        // is Other, and Bash is no longer charged.
805        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        // A harness that says so resets the same way, mid-growth.
810        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        // No usage record: not a response, nothing filed.
825        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    /// Debt #5: the same instant written with an offset parses to the same
866    /// time as its `Z` form, and the fraction and the sign survive.
867    #[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        // A date's own hyphens are not mistaken for a zone sign.
878        assert_eq!(secs("2026-09-03T07:15:34.5Z"), z);
879        // Not timestamps.
880        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}