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;
8
9use crate::model::{Activity, Harness, TokenUsage, ToolSpan};
10use std::collections::VecDeque;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14/// Evidence that the parser still understands the file it is reading.
15///
16/// Every field is read with a fallback to zero, which is the right behaviour
17/// for a genuinely absent field and the wrong behaviour for a renamed one: a
18/// harness that renames `usage` next week would show a user 0 tokens and $0.00
19/// with no error at all. So count the usage records seen and how many of them
20/// yielded nothing. Records present and all of them empty is not a quiet
21/// session, it is a parser that has fallen behind the format.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub struct ParseHealth {
24    /// Model responses seen. Each of these should account for some tokens.
25    pub billable_messages: u64,
26    /// Usage records found on them. Zero of these, with messages present, means
27    /// the record itself moved or was renamed.
28    pub usage_records: u64,
29    /// Records found but yielding nothing, which is what a renamed field inside
30    /// an intact record looks like.
31    pub empty_usage_records: u64,
32}
33
34impl ParseHealth {
35    /// Enough responses to accuse the parser rather than the session. A couple
36    /// of odd messages must not raise the alarm.
37    const MIN_EVIDENCE: u64 = 3;
38
39    /// The session did work that must have cost tokens, and we read none.
40    ///
41    /// Covers both ways a format change reaches us: the usage record moving or
42    /// being renamed, so we never find one, and the fields inside it being
43    /// renamed, so we find records that read as empty. Neither raises an error
44    /// on its own, because every field falls back to zero.
45    pub fn fields_unrecognised(&self) -> bool {
46        self.billable_messages >= Self::MIN_EVIDENCE && self.usage_records == self.empty_usage_records
47    }
48}
49
50#[derive(Debug, Clone, Default)]
51pub struct SessionSummary {
52    pub harness: Option<Harness>,
53    pub session_id: Option<String>,
54    pub cwd: Option<PathBuf>,
55    pub model: Option<String>,
56    pub harness_version: Option<String>,
57    pub usage: TokenUsage,
58    pub cost_usd: f64,
59    pub unpriced_tokens: u64,
60    pub turns: u64,
61    pub subagent_turns: u64,
62    pub tool_calls: u64,
63    pub spans: SpanLog,
64    pub health: ParseHealth,
65    pub activity: Activity,
66    pub started_at: Option<SystemTime>,
67    pub last_activity: Option<SystemTime>,
68}
69
70/// Spans kept per session. A screenful of waterfall is a few dozen rows; the
71/// rest is history nobody scrolls to in a live view, and every span costs a
72/// clone on each refresh.
73pub const MAX_SPANS: usize = 128;
74
75/// A bounded, in-order log of tool spans, built by pairing a harness's
76/// "call started" and "call finished" records by call id.
77///
78/// Records arrive interleaved and out of order (agents run tools in parallel),
79/// so a span is closed by searching back for the still-open span with that id
80/// rather than assuming the most recent one.
81#[derive(Debug, Clone, Default)]
82pub struct SpanLog {
83    spans: VecDeque<ToolSpan>,
84}
85
86impl SpanLog {
87    /// Record the start of a call. Ignored when that id is already open, so a
88    /// transcript line replayed by the harness does not double-count.
89    pub fn open(&mut self, id: String, name: String, at: SystemTime, sidechain: bool) {
90        if id.is_empty() || self.spans.iter().any(|s| s.is_open() && s.id == id) {
91            return;
92        }
93        if self.spans.len() == MAX_SPANS {
94            self.spans.pop_front();
95        }
96        self.spans.push_back(ToolSpan { id, name, started_at: at, duration_ms: None, sidechain, error: false });
97    }
98
99    /// Close the open call with this id. A result whose call scrolled out of
100    /// the window, or that we never saw start, is dropped.
101    pub fn close(&mut self, id: &str, at: SystemTime, error: bool) {
102        let Some(s) = self.spans.iter_mut().rev().find(|s| s.is_open() && s.id == id) else { return };
103        s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
104        s.error = error;
105    }
106
107    pub fn len(&self) -> usize {
108        self.spans.len()
109    }
110
111    pub fn is_empty(&self) -> bool {
112        self.spans.is_empty()
113    }
114
115    /// Oldest first. Double-ended so callers can take the newest spans without
116    /// collecting the whole log first, which the UI and the golden tests both do.
117    pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ToolSpan> + ExactSizeIterator {
118        self.spans.iter()
119    }
120
121    pub fn to_vec(&self) -> Vec<ToolSpan> {
122        self.spans.iter().cloned().collect()
123    }
124}
125
126pub trait SessionTracker {
127    /// Ingest whatever was appended since the last call. Returns true when
128    /// there is still unread data (the byte budget was exhausted).
129    fn refresh(&mut self) -> anyhow::Result<bool>;
130    fn summary(&self) -> &SessionSummary;
131    fn path(&self) -> &Path;
132}
133
134/// Bytes ingested per tracker per refresh. Keeps a cold start on a 100 MB
135/// transcript from freezing the first frame; the rest streams in on later ticks.
136pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
137
138/// Parse an RFC 3339 timestamp like `2026-09-03T07:15:34.123Z` into SystemTime
139/// without pulling in a date crate. Only the UTC `Z` form is handled, which is
140/// what both Claude Code and Codex write.
141pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
142    let s = s.strip_suffix('Z')?;
143    let (date, time) = s.split_once('T')?;
144    let mut d = date.split('-');
145    let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
146    let mut t = time.split(':');
147    let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
148    let sec_str = t.next()?;
149    let (sec, frac) = match sec_str.split_once('.') {
150        Some((s, f)) => (s.parse::<u64>().ok()?, f),
151        None => (sec_str.parse::<u64>().ok()?, ""),
152    };
153    let nanos: u32 = if frac.is_empty() {
154        0
155    } else {
156        let mut f = frac.to_string();
157        f.truncate(9);
158        while f.len() < 9 {
159            f.push('0');
160        }
161        f.parse().ok()?
162    };
163    let days = days_from_civil(y, mo, da);
164    let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64;
165    if secs < 0 {
166        return None;
167    }
168    Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
169}
170
171// Howard Hinnant's days-from-civil algorithm.
172fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
173    let y = if m <= 2 { y - 1 } else { y };
174    let era = if y >= 0 { y } else { y - 399 } / 400;
175    let yoe = y - era * 400;
176    let mp = (m as i64 + 9) % 12;
177    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
178    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
179    era * 146_097 + doe - 719_468
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::time::Duration;
186
187    fn at(secs: u64) -> SystemTime {
188        SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
189    }
190
191    #[test]
192    fn pairs_spans_by_id_out_of_order() {
193        let mut log = SpanLog::default();
194        log.open("a".into(), "Bash".into(), at(10), false);
195        log.open("b".into(), "Read".into(), at(11), true);
196        // Replay of the same start line must not open a second span.
197        log.open("a".into(), "Bash".into(), at(10), false);
198        // Results come back in the other order.
199        log.close("b", at(12), false);
200        log.close("a", at(14), true);
201        // A result with no matching call is ignored.
202        log.close("zzz", at(15), false);
203        let v = log.to_vec();
204        assert_eq!(v.len(), 2);
205        assert_eq!(v[0].name, "Bash");
206        assert_eq!(v[0].duration_ms, Some(4_000));
207        assert!(v[0].error);
208        assert_eq!(v[1].duration_ms, Some(1_000));
209        assert!(v[1].sidechain);
210        assert!(!v[1].error);
211    }
212
213    #[test]
214    fn keeps_the_newest_spans_and_reports_open_ones() {
215        let mut log = SpanLog::default();
216        for i in 0..(MAX_SPANS + 10) {
217            log.open(format!("id{i}"), "T".into(), at(i as u64), false);
218            log.close(&format!("id{i}"), at(i as u64), false);
219        }
220        assert_eq!(log.len(), MAX_SPANS);
221        assert_eq!(log.iter().next().unwrap().id, "id10");
222        log.open("live".into(), "Bash".into(), at(500), false);
223        let last = log.to_vec().pop().unwrap();
224        assert!(last.is_open());
225        assert_eq!(last.elapsed_ms(at(503)), 3_000);
226    }
227
228    #[test]
229    fn accuses_the_parser_only_with_enough_evidence() {
230        // Healthy: records found on the messages, tokens read from them.
231        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
232        assert!(!h.fields_unrecognised());
233        // One odd message among many is a message, not a format change.
234        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
235        assert!(!h.fields_unrecognised());
236        // Fields inside the record renamed: records found, all of them empty.
237        let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
238        assert!(h.fields_unrecognised());
239        // The record itself renamed or moved: messages, but no records at all.
240        // This is the case a naive check misses, because there is nothing to count.
241        let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
242        assert!(h.fields_unrecognised());
243        // Too early to tell: a session that has barely started.
244        let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
245        assert!(!h.fields_unrecognised());
246        // Nothing parsed at all is silence, not evidence.
247        assert!(!ParseHealth::default().fields_unrecognised());
248    }
249
250    #[test]
251    fn parses_timestamps() {
252        let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
253        assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
254        let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
255        let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
256        assert_eq!(secs.as_secs(), 1_788_419_734);
257        assert_eq!(secs.subsec_millis(), 500);
258        assert!(parse_rfc3339_utc("nope").is_none());
259    }
260}