Skip to main content

agent_top_core/harness/
claude.rs

1//! Claude Code: `~/.claude/sessions/<pid>.json` registry and
2//! `~/.claude/projects/<encoded-cwd>/<session>.jsonl` transcripts.
3//!
4//! Format notes (verified on Claude Code 2.1.259, 2026-09-03):
5//! * One API response is written as several lines, one per content block,
6//!   every line carrying the same `message.id` and the same `message.usage`.
7//!   Usage must be counted once per id.
8//! * `usage.cache_creation.ephemeral_1h_input_tokens` /
9//!   `ephemeral_5m_input_tokens` split cache writes by TTL, which have
10//!   different prices.
11//! * Subagents (Claude Code 2.1.233 and later): each Agent-tool call gets its
12//!   own transcript at `<project>/<session>/subagents/agent-<id>.jsonl`, every
13//!   line carrying the parent's `sessionId`, `isSidechain: true` and an
14//!   `agentId`, with `agent-<id>.meta.json` beside it naming the agent type
15//!   and the spawning `toolUseId`. The parent transcript no longer carries any
16//!   sidechain lines itself. Claude Code's own cost display includes those
17//!   files, so `ClaudeTranscript` tails and folds them in.
18//! * A `tool_use` block in an assistant message and the `tool_result` block
19//!   that answers it carry the same id in `id` / `tool_use_id`, and their
20//!   lines carry the timestamps that bracket the call. That pairing is the
21//!   trace: verified 240/240 on a real session.
22//! * The registry file has `status: "busy" | "idle"`, which is the harness's
23//!   own opinion of its state and beats any transcript heuristic.
24
25use super::{REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanLog, SpanRetention, parse_rfc3339_utc};
26use crate::jsonl::TailReader;
27use crate::model::{Activity, Harness, TokenUsage};
28use crate::pricing::{self, Table};
29use serde::Deserialize;
30use serde_json::Value;
31use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33use std::time::{Duration, SystemTime, UNIX_EPOCH};
34
35pub fn home() -> Option<PathBuf> {
36    std::env::var_os("HOME").map(PathBuf::from)
37}
38
39pub fn claude_dir() -> Option<PathBuf> {
40    if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
41        return Some(PathBuf::from(d));
42    }
43    home().map(|h| h.join(".claude"))
44}
45
46pub fn sessions_dir() -> Option<PathBuf> {
47    claude_dir().map(|d| d.join("sessions"))
48}
49
50pub fn projects_dir() -> Option<PathBuf> {
51    claude_dir().map(|d| d.join("projects"))
52}
53
54/// Claude Code's project directory name: every character that is not
55/// ASCII alphanumeric becomes `-`, so `/Users/a/x.y` is `-Users-a-x-y`.
56pub fn encode_project_path(p: &Path) -> String {
57    p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
58}
59
60pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
61    projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
62}
63
64/// One `~/.claude/sessions/<pid>.json`.
65#[derive(Debug, Clone, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct PidSession {
68    pub pid: u32,
69    pub session_id: String,
70    pub cwd: PathBuf,
71    #[serde(default)]
72    pub name: Option<String>,
73    #[serde(default)]
74    pub status: Option<String>,
75    #[serde(default)]
76    pub version: Option<String>,
77    #[serde(default)]
78    pub kind: Option<String>,
79    #[serde(default)]
80    pub entrypoint: Option<String>,
81    #[serde(default)]
82    pub started_at: Option<u64>,
83    #[serde(default)]
84    pub updated_at: Option<u64>,
85}
86
87impl PidSession {
88    pub fn started(&self) -> Option<SystemTime> {
89        self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
90    }
91}
92
93/// Read every registry file. Stale files for dead pids are returned too; the
94/// caller reconciles against the process table.
95pub fn read_pid_sessions() -> Vec<PidSession> {
96    let Some(dir) = sessions_dir() else { return Vec::new() };
97    let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
98    let mut out = Vec::new();
99    for e in rd.flatten() {
100        let p = e.path();
101        if p.extension().and_then(|x| x.to_str()) != Some("json") {
102            continue;
103        }
104        if let Ok(s) = std::fs::read_to_string(&p)
105            && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
106        {
107            out.push(ps);
108        }
109    }
110    out
111}
112
113/// Transcripts modified after `since`, across all projects.
114pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
115    let Some(dir) = projects_dir() else { return Vec::new() };
116    let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
117    let mut out = Vec::new();
118    for proj in projects.flatten() {
119        let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
120        for f in files.flatten() {
121            let p = f.path();
122            if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
123                continue;
124            }
125            if let Ok(md) = f.metadata()
126                && md.modified().map(|m| m >= since).unwrap_or(false)
127            {
128                out.push(p);
129            }
130        }
131    }
132    out
133}
134
135/// Fallback attribution when the registry has no entry: the transcript in
136/// the cwd's project directory created closest after the process start.
137pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
138    let dir = projects_dir()?.join(encode_project_path(cwd));
139    let rd = std::fs::read_dir(&dir).ok()?;
140    let slack = Duration::from_secs(15);
141    let mut best: Option<(Duration, PathBuf)> = None;
142    for f in rd.flatten() {
143        let p = f.path();
144        if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
145            continue;
146        }
147        // A file we cannot stat is skipped, not fatal: one unreadable
148        // transcript must not abandon attribution for the whole directory.
149        let Ok(md) = f.metadata() else { continue };
150        let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
151        if created + slack < proc_start {
152            continue;
153        }
154        let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
155        if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
156            best = Some((gap, p));
157        }
158    }
159    best.map(|(_, p)| p)
160}
161
162/// Where Claude Code keeps a session's subagent transcripts: one
163/// `agent-<id>.jsonl` per Agent-tool call, next to an `agent-<id>.meta.json`
164/// naming the agent type and the `toolUseId` that spawned it.
165pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
166    let stem = transcript.file_stem()?;
167    Some(transcript.with_file_name(stem).join("subagents"))
168}
169
170/// One JSONL file being tailed into a `SessionSummary`: the main transcript,
171/// or one subagent's.
172struct Parser {
173    reader: TailReader,
174    summary: SessionSummary,
175    /// Dedupe state: the last API message id seen and what it contributed.
176    last_msg_id: Option<String>,
177    last_contrib: (TokenUsage, f64, u64),
178}
179
180impl Parser {
181    fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
182        Parser {
183            reader: TailReader::new(path),
184            summary: SessionSummary { harness: Some(Harness::Claude), spans: spans.log(), ..Default::default() },
185            last_msg_id: None,
186            last_contrib: (TokenUsage::default(), 0.0, 0),
187        }
188    }
189
190    /// Returns how many lines were ingested and whether more are waiting.
191    fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
192        let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
193        for l in &lines {
194            self.ingest(l, prices);
195        }
196        Ok((lines.len(), more))
197    }
198
199    fn ingest(&mut self, line: &str, prices: &Table) {
200        let Ok(v) = serde_json::from_str::<Value>(line) else { return };
201        let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
202        if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
203            if self.summary.started_at.is_none() {
204                self.summary.started_at = Some(ts);
205            }
206            self.summary.last_activity = Some(ts);
207        }
208        if self.summary.session_id.is_none() {
209            self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
210        }
211        if self.summary.cwd.is_none() {
212            self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
213        }
214        if self.summary.harness_version.is_none() {
215            self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
216        }
217        let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
218        let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
219        let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
220        match kind {
221            "assistant" => self.ingest_assistant(&v, sidechain, ts, prices),
222            "user" if !is_meta => {
223                // Either a prompt or a tool_result: in both cases the model owes a response.
224                self.summary.activity = Activity::Working;
225                if let Some(ts) = ts {
226                    self.close_spans(&v, ts);
227                }
228            }
229            _ => {}
230        }
231    }
232
233    /// A user line answering tool calls: every `tool_result` block closes a span.
234    fn close_spans(&mut self, v: &Value, ts: SystemTime) {
235        let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return };
236        for b in content {
237            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
238                continue;
239            }
240            let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
241            self.summary.spans.close(id, ts, b.get("is_error").and_then(Value::as_bool).unwrap_or(false));
242        }
243    }
244
245    fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
246        let Some(msg) = v.get("message") else { return };
247        let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
248        let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
249        if !model.is_empty() && model != "<synthetic>" {
250            self.summary.model = Some(model.to_string());
251        }
252        if let Some(content) = msg.get("content").and_then(Value::as_array) {
253            let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
254            for b in calls {
255                self.summary.tool_calls += 1;
256                if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
257                    let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
258                    self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
259                }
260            }
261        }
262        match msg.get("stop_reason").and_then(Value::as_str) {
263            Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
264                self.summary.activity = Activity::Waiting;
265            }
266            _ => self.summary.activity = Activity::Working,
267        }
268
269        // Health is judged on the record being present but unreadable, which is
270        // what a renamed field looks like from in here.
271        if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
272            self.summary.health.billable_messages += 1;
273        }
274        let usage = match msg.get("usage") {
275            Some(u) => {
276                let parsed = parse_usage(u);
277                self.summary.health.usage_records += 1;
278                if parsed.total() == 0 {
279                    self.summary.health.empty_usage_records += 1;
280                }
281                parsed
282            }
283            None => TokenUsage::default(),
284        };
285        let price = prices.lookup(model);
286        let cost = price.map(|p| p.cost(&usage)).unwrap_or(0.0);
287        let unpriced = if price.is_none() { usage.total() } else { 0 };
288
289        let same_message = id.is_some() && id == self.last_msg_id;
290        if same_message {
291            // Replace the previous contribution from this id with the latest one.
292            let (u, c, un) = self.last_contrib;
293            self.summary.usage.sub(&u);
294            self.summary.cost_usd -= c;
295            self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(un);
296        } else {
297            self.summary.turns += 1;
298            if sidechain {
299                self.summary.subagent_turns += 1;
300            }
301        }
302        self.summary.usage.add(&usage);
303        self.summary.cost_usd += cost;
304        self.summary.unpriced_tokens += unpriced;
305        self.last_msg_id = id;
306        self.last_contrib = (usage, cost, unpriced);
307    }
308}
309
310/// A Claude Code session: the main transcript plus every subagent transcript
311/// under its `subagents/` directory, folded into one summary.
312///
313/// Claude Code bills a subagent's API calls to the session that spawned it
314/// and shows them in its own cost display, but writes them to a separate
315/// file, so a session that used the Agent tool reads low if only the main
316/// transcript is counted. Each subagent file is tailed like the main one and
317/// its tokens, cost, turns, tool calls and spans are added to the parent's.
318/// A subagent may run a different model from its parent; each line is priced
319/// by the model it names, so that is handled without special casing.
320pub struct ClaudeTranscript {
321    main: Parser,
322    /// Keyed by path, so a directory listing adds each subagent once.
323    subagents: BTreeMap<PathBuf, Parser>,
324    prices: &'static Table,
325    retention: SpanRetention,
326    /// The fold of `main` and `subagents`, rebuilt whenever any of them read
327    /// a line. Cheap: a clone of the main summary and a merge of the span logs.
328    summary: SessionSummary,
329}
330
331impl ClaudeTranscript {
332    pub fn new(path: impl Into<PathBuf>) -> Self {
333        let retention = SpanRetention::Recent;
334        ClaudeTranscript {
335            main: Parser::new(path, retention),
336            subagents: BTreeMap::new(),
337            prices: pricing::table(),
338            retention,
339            summary: SessionSummary { harness: Some(Harness::Claude), ..Default::default() },
340        }
341    }
342
343    /// Price with this table instead of the process-wide one. Lets a test
344    /// assert a cost without the developer's own price file changing it.
345    pub fn with_prices(mut self, prices: &'static Table) -> Self {
346        self.prices = prices;
347        self
348    }
349
350    /// Keep every span instead of the newest `MAX_SPANS`. See `SpanRetention`.
351    pub fn with_spans(mut self, retention: SpanRetention) -> Self {
352        self.retention = retention;
353        self.main.summary.spans = retention.log();
354        for p in self.subagents.values_mut() {
355            p.summary.spans = retention.log();
356        }
357        self
358    }
359
360    pub fn set_registry_hints(&mut self, ps: &PidSession) {
361        let s = &mut self.main.summary;
362        s.session_id.get_or_insert_with(|| ps.session_id.clone());
363        s.cwd.get_or_insert_with(|| ps.cwd.clone());
364        if ps.version.is_some() {
365            s.harness_version = ps.version.clone();
366        }
367        if s.started_at.is_none() {
368            s.started_at = ps.started();
369        }
370        self.fold();
371    }
372
373    /// Pick up subagent transcripts that appeared since the last look. One
374    /// directory listing per refresh; the directory is small and usually
375    /// absent, so this is a single failed `open` for most sessions.
376    fn discover_subagents(&mut self) {
377        let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
378        let Ok(rd) = std::fs::read_dir(&dir) else { return };
379        for e in rd.flatten() {
380            let p = e.path();
381            if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
382                continue;
383            }
384            let parser = Parser::new(&p, self.retention);
385            self.subagents.insert(p, parser);
386        }
387    }
388
389    fn fold(&mut self) {
390        let mut s = self.main.summary.clone();
391        for c in self.subagents.values() {
392            let t = &c.summary;
393            s.usage.add(&t.usage);
394            s.cost_usd += t.cost_usd;
395            s.unpriced_tokens += t.unpriced_tokens;
396            s.turns += t.turns;
397            s.subagent_turns += t.subagent_turns;
398            s.tool_calls += t.tool_calls;
399            s.health.billable_messages += t.health.billable_messages;
400            s.health.usage_records += t.health.usage_records;
401            s.health.empty_usage_records += t.health.empty_usage_records;
402            s.last_activity = s.last_activity.max(t.last_activity);
403        }
404        if !self.subagents.is_empty() {
405            let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
406            s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
407        }
408        self.summary = s;
409    }
410}
411
412fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
413    matches!((a, b), (Some(x), Some(y)) if x == y)
414}
415
416fn parse_usage(u: &Value) -> TokenUsage {
417    let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
418    let cache_write_total = g("cache_creation_input_tokens");
419    let (w1h, w5m) = match u.get("cache_creation") {
420        Some(cc) => (
421            cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
422            cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
423        ),
424        None => (0, 0),
425    };
426    // Older transcripts have only the total; treat it as 5-minute writes.
427    let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
428    TokenUsage {
429        input: g("input_tokens"),
430        cache_write_5m: w5m,
431        cache_write_1h: w1h,
432        cache_read: g("cache_read_input_tokens"),
433        output: g("output_tokens"),
434    }
435}
436
437impl SessionTracker for ClaudeTranscript {
438    fn refresh(&mut self) -> anyhow::Result<bool> {
439        let (mut ingested, mut more) = self.main.refresh(self.prices)?;
440        self.discover_subagents();
441        for c in self.subagents.values_mut() {
442            // One unreadable subagent file must not take the session with it.
443            if let Ok((n, m)) = c.refresh(self.prices) {
444                ingested += n;
445                more |= m;
446            }
447        }
448        if ingested > 0 || self.summary.session_id.is_none() {
449            self.fold();
450        }
451        Ok(more)
452    }
453
454    fn summary(&self) -> &SessionSummary {
455        &self.summary
456    }
457
458    fn path(&self) -> &Path {
459        self.main.reader.path()
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use std::io::Write;
467
468    #[test]
469    fn encodes_paths_like_claude_code() {
470        assert_eq!(
471            encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
472            "-Users-atlas-Documents-orbital-forge-agent-top"
473        );
474        assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
475    }
476
477    #[test]
478    fn dedupes_usage_by_message_id_and_tracks_state() {
479        let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
480        std::fs::create_dir_all(&dir).unwrap();
481        let path = dir.join("s.jsonl");
482        let mut f = std::fs::File::create(&path).unwrap();
483        let usage = r#"{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000,"output_tokens":50,"cache_creation":{"ephemeral_1h_input_tokens":100,"ephemeral_5m_input_tokens":0}}"#;
484        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
485        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.000Z","message":{{"id":"msg_1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"text","text":"x"}}],"usage":{usage}}}}}"#).unwrap();
486        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:02.000Z","message":{{"id":"msg_1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","name":"Bash"}}],"usage":{usage}}}}}"#).unwrap();
487        let mut t = ClaudeTranscript::new(&path);
488        t.refresh().unwrap();
489        let s = t.summary();
490        assert_eq!(s.turns, 1);
491        assert_eq!(s.tool_calls, 1);
492        assert_eq!(s.usage.total(), 1152);
493        assert_eq!(s.activity, Activity::Working);
494        assert_eq!(s.session_id.as_deref(), Some("abc"));
495        // sonnet-5: 2*2 + 100*4 + 1000*0.2 + 50*10 = 4 + 400 + 200 + 500 = 1104 micro-dollars
496        assert!((s.cost_usd - 0.001104).abs() < 1e-9);
497        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:03.000Z","message":{{"id":"msg_2","model":"claude-sonnet-5","stop_reason":"end_turn","content":[],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
498        t.refresh().unwrap();
499        assert_eq!(t.summary().turns, 2);
500        assert_eq!(t.summary().activity, Activity::Waiting);
501        let _ = std::fs::remove_dir_all(&dir);
502    }
503
504    #[test]
505    fn folds_subagent_transcripts_into_the_parent() {
506        let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
507        let _ = std::fs::remove_dir_all(&dir);
508        std::fs::create_dir_all(&dir).unwrap();
509        let path = dir.join("s.jsonl");
510        let mut f = std::fs::File::create(&path).unwrap();
511        // The parent spawns an Agent-tool call at 07:00:00, which is still running.
512        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"id":"m1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"toolu_agent","name":"Agent"}}],"usage":{{"input_tokens":100,"output_tokens":10}}}}}}"#).unwrap();
513        let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
514        t.refresh().unwrap();
515        assert_eq!(t.summary().usage.total(), 110);
516        assert_eq!(t.summary().subagent_turns, 0);
517        // sonnet-5: 100*2 + 10*10 = 300 micro-dollars
518        assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
519
520        // A subagent transcript appears, on a different model, with its own tool call.
521        let sub = subagents_dir(&path).unwrap();
522        std::fs::create_dir_all(&sub).unwrap();
523        let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
524        std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
525        writeln!(g, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.000Z","sessionId":"abc","isSidechain":true,"agentId":"a1","message":{{"id":"s1","model":"claude-opus-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"toolu_sub","name":"Grep"}}],"usage":{{"input_tokens":1000,"output_tokens":100}}}}}}"#).unwrap();
526        writeln!(g, r#"{{"type":"user","timestamp":"2026-09-03T07:00:03.000Z","sessionId":"abc","isSidechain":true,"agentId":"a1","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_sub"}}]}}}}"#).unwrap();
527        t.refresh().unwrap();
528        let s = t.summary();
529        assert_eq!(s.usage.total(), 1210);
530        assert_eq!(s.turns, 2);
531        assert_eq!(s.subagent_turns, 1);
532        assert_eq!(s.tool_calls, 2);
533        // opus-5: 1000*5 + 100*25 = 7500 micro-dollars, on top of the parent's 300
534        assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
535        assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
536        assert_eq!(s.session_id.as_deref(), Some("abc"));
537        let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
538        assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
539        let spans = s.spans.to_vec();
540        assert_eq!(spans.len(), 2);
541        assert_eq!(spans[0].name, "Agent");
542        assert!(spans[0].is_open());
543        assert_eq!(spans[1].name, "Grep");
544        assert!(spans[1].sidechain);
545        assert_eq!(spans[1].duration_ms, Some(2_000));
546
547        // The subagent keeps writing; only the new lines are read.
548        writeln!(g, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:04.000Z","sessionId":"abc","isSidechain":true,"agentId":"a1","message":{{"id":"s2","model":"claude-opus-5","stop_reason":"end_turn","content":[],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
549        t.refresh().unwrap();
550        assert_eq!(t.summary().usage.total(), 1212);
551        assert_eq!(t.summary().subagent_turns, 2);
552        let _ = std::fs::remove_dir_all(&dir);
553    }
554
555    #[test]
556    fn builds_spans_from_tool_use_and_tool_result() {
557        let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
558        std::fs::create_dir_all(&dir).unwrap();
559        let path = dir.join("s.jsonl");
560        let mut f = std::fs::File::create(&path).unwrap();
561        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:00.000Z","message":{{"id":"m1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"toolu_a","name":"Bash"}},{{"type":"tool_use","id":"toolu_b","name":"Read"}}],"usage":{{"input_tokens":1}}}}}}"#).unwrap();
562        // Results arrive on one line, in the other order, one of them failed.
563        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:02.500Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_b","is_error":true}},{{"type":"tool_result","tool_use_id":"toolu_a","is_error":false}}]}},"toolUseResult":{{}}}}"#).unwrap();
564        // A subagent call that has not come back yet.
565        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:03.000Z","isSidechain":true,"message":{{"id":"m2","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"toolu_c","name":"Grep"}}],"usage":{{"input_tokens":1}}}}}}"#).unwrap();
566        let mut t = ClaudeTranscript::new(&path);
567        t.refresh().unwrap();
568        let spans = t.summary().spans.to_vec();
569        assert_eq!(spans.len(), 3);
570        assert_eq!(spans[0].name, "Bash");
571        assert_eq!(spans[0].duration_ms, Some(2_500));
572        assert!(!spans[0].error);
573        assert_eq!(spans[1].name, "Read");
574        assert_eq!(spans[1].duration_ms, Some(2_500));
575        assert!(spans[1].error);
576        assert!(spans[2].is_open());
577        assert!(spans[2].sidechain);
578        assert_eq!(t.summary().tool_calls, 3);
579        let _ = std::fs::remove_dir_all(&dir);
580    }
581}