1use super::{
50 AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, RegistryHints, SessionSummary, SessionTracker, SpanLog, SpanRetention,
51 mcp_server_of, parse_rfc3339_utc,
52};
53use crate::jsonl::TailReader;
54use crate::model::{Activity, Attribution, ContextOrigin, CostBreakdown, Harness, HarnessCost, ProcNode, SpanKind, TokenUsage};
55use crate::pricing::{self, Table};
56use crate::process::{RawProc, session_id_from_args};
57use serde::Deserialize;
58use serde_json::Value;
59use std::collections::{BTreeMap, HashMap, HashSet};
60use std::path::{Path, PathBuf};
61use std::time::{Duration, SystemTime, UNIX_EPOCH};
62
63pub fn home() -> Option<PathBuf> {
64 std::env::var_os("HOME").map(PathBuf::from)
65}
66
67pub fn claude_dir() -> Option<PathBuf> {
68 if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
69 return Some(PathBuf::from(d));
70 }
71 home().map(|h| h.join(".claude"))
72}
73
74pub fn sessions_dir() -> Option<PathBuf> {
75 claude_dir().map(|d| d.join("sessions"))
76}
77
78pub fn projects_dir() -> Option<PathBuf> {
79 claude_dir().map(|d| d.join("projects"))
80}
81
82pub fn encode_project_path(p: &Path) -> String {
85 p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
86}
87
88pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
89 projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
90}
91
92#[derive(Debug, Clone, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PidSession {
96 pub pid: u32,
97 pub session_id: String,
98 pub cwd: PathBuf,
99 #[serde(default)]
100 pub name: Option<String>,
101 #[serde(default)]
102 pub status: Option<String>,
103 #[serde(default)]
104 pub version: Option<String>,
105 #[serde(default)]
106 pub kind: Option<String>,
107 #[serde(default)]
108 pub entrypoint: Option<String>,
109 #[serde(default)]
110 pub started_at: Option<u64>,
111 #[serde(default)]
112 pub updated_at: Option<u64>,
113}
114
115impl PidSession {
116 pub fn started(&self) -> Option<SystemTime> {
117 self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
118 }
119}
120
121pub fn read_pid_sessions() -> Vec<PidSession> {
124 let Some(dir) = sessions_dir() else { return Vec::new() };
125 let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
126 let mut out = Vec::new();
127 for e in rd.flatten() {
128 let p = e.path();
129 if p.extension().and_then(|x| x.to_str()) != Some("json") {
130 continue;
131 }
132 if let Ok(s) = std::fs::read_to_string(&p)
133 && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
134 {
135 out.push(ps);
136 }
137 }
138 out
139}
140
141pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
143 let Some(dir) = projects_dir() else { return Vec::new() };
144 let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
145 let mut out = Vec::new();
146 for proj in projects.flatten() {
147 let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
148 for f in files.flatten() {
149 let p = f.path();
150 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
151 continue;
152 }
153 if let Ok(md) = f.metadata()
154 && md.modified().map(|m| m >= since).unwrap_or(false)
155 {
156 out.push(p);
157 }
158 }
159 }
160 out
161}
162
163pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
166 let dir = projects_dir()?.join(encode_project_path(cwd));
167 let rd = std::fs::read_dir(&dir).ok()?;
168 let slack = Duration::from_secs(15);
169 let mut best: Option<(Duration, PathBuf)> = None;
170 for f in rd.flatten() {
171 let p = f.path();
172 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
173 continue;
174 }
175 let Ok(md) = f.metadata() else { continue };
178 let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
179 if created + slack < proc_start {
180 continue;
181 }
182 let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
183 if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
184 best = Some((gap, p));
185 }
186 }
187 best.map(|(_, p)| p)
188}
189
190pub fn is_subagent_transcript(p: &Path) -> bool {
195 p.file_name().and_then(|f| f.to_str()).map(|f| f.starts_with("agent-")).unwrap_or(false)
196}
197
198#[derive(Default)]
201pub struct ClaudeAdapter {
202 recent: Vec<PathBuf>,
203 registry: HashMap<u32, PidSession>,
204}
205
206impl HarnessAdapter for ClaudeAdapter {
207 fn harness(&self) -> Harness {
208 Harness::Claude
209 }
210
211 fn rescan(&mut self, since: SystemTime) {
212 self.recent = recent_transcripts(since);
213 }
214
215 fn prepare(&mut self, _roots: &[&ProcNode], _by_pid: &HashMap<u32, &RawProc>) {
218 self.registry = read_pid_sessions().into_iter().map(|s| (s.pid, s)).collect();
219 }
220
221 fn hints(&self, pid: u32) -> Option<RegistryHints> {
222 self.registry.get(&pid).map(|r| RegistryHints {
223 name: r.name.clone(),
224 session_id: Some(r.session_id.clone()),
225 cwd: Some(r.cwd.clone()),
226 version: r.version.clone(),
227 status: r.status.clone(),
228 })
229 }
230
231 fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
232 if let Some(reg) = self.registry.get(&root.pid)
233 && let Some(p) = transcript_path(®.cwd, ®.session_id)
234 {
235 return (vec![p], Attribution::HarnessRegistry);
236 }
237 if let (Some(raw), Some(cwd)) = (raw, ctx.cwd)
238 && let Some(id) = session_id_from_args(&raw.cmd)
239 && let Some(p) = transcript_path(cwd, &id)
240 && p.exists()
241 {
242 return (vec![p], Attribution::CommandLine);
243 }
244 if let Some(cwd) = ctx.cwd
245 && let Some(p) = guess_transcript(cwd, ctx.proc_start)
246 && !ctx.attached.contains(&p)
247 {
248 return (vec![p], Attribution::CwdHeuristic);
249 }
250 (Vec::new(), Attribution::None)
251 }
252
253 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
254 self.recent.iter().filter(|p| !attached.contains(*p) && !is_subagent_transcript(p)).cloned().collect()
255 }
256
257 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
258 Box::new(ClaudeTranscript::new(path).with_spans(spans))
259 }
260
261 fn detect(&self, path: &Path) -> bool {
264 super::head_lines(path)
265 .iter()
266 .any(|v| (v.get("sessionId").is_some() || v.get("parentUuid").is_some()) && v.get("projectHash").is_none())
267 }
268
269 fn transcripts(&self) -> Vec<(String, PathBuf)> {
271 recent_transcripts(SystemTime::UNIX_EPOCH)
272 .into_iter()
273 .filter(|p| !is_subagent_transcript(p))
274 .map(|p| (p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), p))
275 .collect()
276 }
277}
278
279pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
283 let stem = transcript.file_stem()?;
284 Some(transcript.with_file_name(stem).join("subagents"))
285}
286
287struct Parser {
290 reader: TailReader,
291 summary: SessionSummary,
292 last_msg_id: Option<String>,
294 last_contrib: Contrib,
295 inference: Option<String>,
298 turn: Option<String>,
299 inferences: u64,
300 turns: u64,
301 prev_ts: Option<SystemTime>,
305 inference_ended_by: Option<String>,
310 turn_ended_by: Option<String>,
311 pending_tools: HashMap<String, String>,
315}
316
317#[derive(Debug, Clone, Copy, Default)]
320struct Contrib {
321 usage: TokenUsage,
322 cost: CostBreakdown,
323 unpriced: u64,
324 searches: u64,
325}
326
327impl Parser {
328 fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
329 Parser {
330 reader: TailReader::new(path),
331 summary: SessionSummary { harness: Some(Harness::Claude), folds_child_usage: true, spans: spans.log(), ..Default::default() },
332 last_msg_id: None,
333 last_contrib: Contrib::default(),
334 inference: None,
335 turn: None,
336 inferences: 0,
337 turns: 0,
338 prev_ts: None,
339 inference_ended_by: None,
340 turn_ended_by: None,
341 pending_tools: HashMap::new(),
342 }
343 }
344
345 fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
347 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
348 for l in &lines {
349 self.ingest(l, prices);
350 }
351 Ok((lines.len(), more))
352 }
353
354 fn ingest(&mut self, line: &str, prices: &Table) {
355 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
356 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
357 if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
358 if self.summary.started_at.is_none() {
359 self.summary.started_at = Some(ts);
360 }
361 self.summary.last_activity = Some(ts);
362 }
363 if self.summary.session_id.is_none() {
364 self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
365 }
366 if self.summary.cwd.is_none() {
367 self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
368 }
369 if self.summary.harness_version.is_none() {
370 self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
371 }
372 let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
373 let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
374 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
375 match kind {
376 "assistant" => {
377 self.ingest_assistant(&v, sidechain, ts, prices);
378 if let Some(h) = &mut self.summary.harness_cost {
379 h.current = false;
380 }
381 }
382 "user" if !is_meta => {
383 self.summary.activity = Activity::Working;
385 if let Some(ts) = ts {
386 let answered = self.close_spans(&v, ts);
387 if !answered {
388 self.begin_turn(ts, sidechain);
389 }
390 self.begin_inference(ts, sidechain);
391 }
392 }
393 "user" => {
398 if let Some(ts) = ts {
399 self.begin_inference(ts, sidechain);
400 }
401 }
402 "system" if v.get("subtype").and_then(Value::as_str) == Some("compact_boundary") => {
403 self.summary.context.compacted();
404 }
405 "cost-state" => {
406 if let Some(usd) = v.get("totalCostUSD").and_then(Value::as_f64) {
407 self.summary.harness_cost = Some(HarnessCost {
408 usd,
409 lower_bound: v.get("hasUnknownModelCost").and_then(Value::as_bool).unwrap_or(false),
410 as_of: self.summary.last_activity,
411 current: true,
412 });
413 }
414 }
415 _ => {}
416 }
417 if ts.is_some() {
418 self.prev_ts = ts;
419 }
420 }
421
422 fn begin_turn(&mut self, ts: SystemTime, sidechain: bool) {
426 if let Some(id) = self.turn.take()
427 && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
428 {
429 let ended = self.prev_ts.unwrap_or(ts).min(ts);
430 self.summary.spans.end_at(&id, ended);
431 }
432 self.turns += 1;
433 let id = format!("turn:{}", self.turns);
434 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, sidechain, SpanKind::Turn);
435 self.turn = Some(id);
436 self.turn_ended_by = None;
437 }
438
439 fn begin_inference(&mut self, ts: SystemTime, sidechain: bool) {
444 if let Some(id) = self.inference.take() {
445 self.summary.spans.discard_open(&id);
446 }
447 self.inferences += 1;
448 let id = format!("inference:{}", self.inferences);
449 self.summary.spans.open_kind(id.clone(), "inference".into(), ts, sidechain, SpanKind::Inference);
450 self.inference = Some(id);
451 self.inference_ended_by = None;
452 }
453
454 fn may_extend(ended_by: &mut Option<String>, id: Option<&str>) -> bool {
458 match (ended_by.as_deref(), id) {
459 (None, Some(id)) => {
460 *ended_by = Some(id.to_string());
461 true
462 }
463 (None, None) => true,
464 (Some(e), Some(id)) => e == id,
465 (Some(_), None) => false,
466 }
467 }
468
469 fn close_spans(&mut self, v: &Value, ts: SystemTime) -> bool {
473 let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return false };
474 let mut answered = false;
475 for b in content {
476 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
477 continue;
478 }
479 answered = true;
480 let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
481 let error = b.get("is_error").and_then(Value::as_bool).unwrap_or(false);
482 self.summary.spans.close(id, ts, error);
483 let name = self.pending_tools.remove(id).unwrap_or_else(|| "tool".into());
484 match mcp_server_of(&name) {
485 Some(server) => {
486 if error {
487 self.summary.mcp.entry(server.to_string()).or_default().errors += 1;
488 }
489 self.summary.context.result(id, ContextOrigin::Mcp, server);
490 }
491 None => self.summary.context.result(id, ContextOrigin::Tool, &name),
492 }
493 }
494 answered
495 }
496
497 fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
498 let Some(msg) = v.get("message") else { return };
499 let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
500 let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
501 if !model.is_empty() && model != "<synthetic>" {
502 self.summary.model = Some(model.to_string());
503 }
504 if let Some(content) = msg.get("content").and_then(Value::as_array) {
505 let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
506 for b in calls {
507 self.summary.tool_calls += 1;
508 let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
509 if let Some(server) = mcp_server_of(name) {
510 let u = self.summary.mcp.entry(server.to_string()).or_default();
511 u.calls += 1;
512 u.last_call = u.last_call.max(ts);
513 }
514 if let Some(id) = b.get("id").and_then(Value::as_str) {
515 self.pending_tools.insert(id.to_string(), name.to_string());
516 }
517 if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
518 self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
519 }
520 }
521 }
522 let synthetic = model == "<synthetic>";
527 if let (Some(ts), Some(span), false) = (ts, self.inference.as_deref(), synthetic) {
528 if Self::may_extend(&mut self.inference_ended_by, id.as_deref()) {
529 self.summary.spans.end_at(span, ts);
530 } else {
531 self.inference = None;
532 }
533 }
534 match msg.get("stop_reason").and_then(Value::as_str) {
535 Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
536 self.summary.activity = Activity::Waiting;
537 if let (Some(ts), Some(span), false) = (ts, self.turn.as_deref(), synthetic) {
541 if Self::may_extend(&mut self.turn_ended_by, id.as_deref()) {
542 self.summary.spans.end_at(span, ts);
543 } else {
544 self.turn = None;
545 }
546 }
547 }
548 _ => self.summary.activity = Activity::Working,
549 }
550
551 if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
554 self.summary.health.billable_messages += 1;
555 }
556 let usage = match msg.get("usage") {
557 Some(u) => {
558 let parsed = parse_usage(u);
559 self.summary.health.usage_records += 1;
560 if parsed.total() == 0 {
561 self.summary.health.empty_usage_records += 1;
562 }
563 parsed
564 }
565 None => TokenUsage::default(),
566 };
567 let price = prices.lookup(model);
568 let searches = msg.pointer("/usage/server_tool_use/web_search_requests").and_then(Value::as_u64).unwrap_or(0);
571 let mut cost = price.map(|p| p.breakdown(&usage)).unwrap_or_default();
572 cost.web_search = prices.web_search_cost(searches);
573 let unpriced = if price.is_none() { usage.total() } else { 0 };
574
575 let same_message = id.is_some() && id == self.last_msg_id;
576 if same_message {
577 let c = self.last_contrib;
579 self.summary.usage.sub(&c.usage);
580 self.summary.cost_usd -= c.cost.total();
581 self.summary.cost_breakdown.sub(&c.cost);
582 self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(c.unpriced);
583 self.summary.web_searches = self.summary.web_searches.saturating_sub(c.searches);
584 } else {
585 self.summary.turns += 1;
586 if sidechain {
587 self.summary.subagent_turns += 1;
588 }
589 self.summary.context.response(&usage, &cost);
592 }
593 self.summary.usage.add(&usage);
594 self.summary.cost_usd += cost.total();
595 self.summary.cost_breakdown.add(&cost);
596 self.summary.unpriced_tokens += unpriced;
597 self.summary.web_searches += searches;
598 self.last_msg_id = id;
599 self.last_contrib = Contrib { usage, cost, unpriced, searches };
600 }
601}
602
603pub struct ClaudeTranscript {
614 main: Parser,
615 subagents: BTreeMap<PathBuf, Parser>,
617 prices: &'static Table,
618 retention: SpanRetention,
619 summary: SessionSummary,
622}
623
624impl ClaudeTranscript {
625 pub fn new(path: impl Into<PathBuf>) -> Self {
626 let retention = SpanRetention::Recent;
627 ClaudeTranscript {
628 main: Parser::new(path, retention),
629 subagents: BTreeMap::new(),
630 prices: pricing::table(),
631 retention,
632 summary: SessionSummary { harness: Some(Harness::Claude), folds_child_usage: true, ..Default::default() },
633 }
634 }
635
636 pub fn with_prices(mut self, prices: &'static Table) -> Self {
639 self.prices = prices;
640 self
641 }
642
643 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
645 self.retention = retention;
646 self.main.summary.spans = retention.log();
647 for p in self.subagents.values_mut() {
648 p.summary.spans = retention.log();
649 }
650 self
651 }
652
653 pub fn set_registry_hints(&mut self, ps: &PidSession) {
654 let s = &mut self.main.summary;
655 s.session_id.get_or_insert_with(|| ps.session_id.clone());
656 s.cwd.get_or_insert_with(|| ps.cwd.clone());
657 if ps.version.is_some() {
658 s.harness_version = ps.version.clone();
659 }
660 if s.started_at.is_none() {
661 s.started_at = ps.started();
662 }
663 self.fold();
664 }
665
666 fn discover_subagents(&mut self) {
670 let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
671 let Ok(rd) = std::fs::read_dir(&dir) else { return };
672 for e in rd.flatten() {
673 let p = e.path();
674 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
675 continue;
676 }
677 let parser = Parser::new(&p, self.retention);
678 self.subagents.insert(p, parser);
679 }
680 }
681
682 fn fold(&mut self) {
683 let mut s = self.main.summary.clone();
684 for c in self.subagents.values() {
685 let t = &c.summary;
686 s.usage.add(&t.usage);
687 s.cost_usd += t.cost_usd;
688 s.cost_breakdown.add(&t.cost_breakdown);
689 s.unpriced_tokens += t.unpriced_tokens;
690 s.turns += t.turns;
691 s.subagent_turns += t.subagent_turns;
692 s.tool_calls += t.tool_calls;
693 s.web_searches += t.web_searches;
694 s.health.billable_messages += t.health.billable_messages;
695 s.health.usage_records += t.health.usage_records;
696 s.health.empty_usage_records += t.health.empty_usage_records;
697 s.last_activity = s.last_activity.max(t.last_activity);
698 for (server, u) in &t.mcp {
699 s.mcp.entry(server.clone()).or_default().add(u);
700 }
701 s.context.merge(&t.context);
702 if let Some(h) = &mut s.harness_cost
704 && t.last_activity > h.as_of
705 && t.health.usage_records > 0
706 {
707 h.current = false;
708 }
709 }
710 if !self.subagents.is_empty() {
711 let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
712 s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
713 }
714 self.summary = s;
715 }
716}
717
718fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
719 matches!((a, b), (Some(x), Some(y)) if x == y)
720}
721
722fn parse_usage(u: &Value) -> TokenUsage {
723 let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
724 let cache_write_total = g("cache_creation_input_tokens");
725 let (w1h, w5m) = match u.get("cache_creation") {
726 Some(cc) => (
727 cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
728 cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
729 ),
730 None => (0, 0),
731 };
732 let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
734 TokenUsage {
735 input: g("input_tokens"),
736 cache_write_5m: w5m,
737 cache_write_1h: w1h,
738 cache_write_unsplit: 0,
740 cache_read: g("cache_read_input_tokens"),
741 output: g("output_tokens"),
742 }
743}
744
745impl SessionTracker for ClaudeTranscript {
746 fn refresh(&mut self) -> anyhow::Result<bool> {
747 let (mut ingested, mut more) = self.main.refresh(self.prices)?;
748 self.discover_subagents();
749 for c in self.subagents.values_mut() {
750 if let Ok((n, m)) = c.refresh(self.prices) {
752 ingested += n;
753 more |= m;
754 }
755 }
756 if ingested > 0 || self.summary.session_id.is_none() {
757 self.fold();
758 }
759 Ok(more)
760 }
761
762 fn summary(&self) -> &SessionSummary {
763 &self.summary
764 }
765
766 fn path(&self) -> &Path {
767 self.main.reader.path()
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use std::io::Write;
775
776 #[test]
777 fn encodes_paths_like_claude_code() {
778 assert_eq!(
779 encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
780 "-Users-atlas-Documents-orbital-forge-agent-top"
781 );
782 assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
783 }
784
785 #[test]
786 fn dedupes_usage_by_message_id_and_tracks_state() {
787 let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
788 std::fs::create_dir_all(&dir).unwrap();
789 let path = dir.join("s.jsonl");
790 let mut f = std::fs::File::create(&path).unwrap();
791 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}}"#;
792 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
793 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();
794 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();
795 let mut t = ClaudeTranscript::new(&path);
796 t.refresh().unwrap();
797 let s = t.summary();
798 assert_eq!(s.turns, 1);
799 assert_eq!(s.tool_calls, 1);
800 assert_eq!(s.usage.total(), 1152);
801 assert_eq!(s.activity, Activity::Working);
802 assert_eq!(s.session_id.as_deref(), Some("abc"));
803 assert!((s.cost_usd - 0.001104).abs() < 1e-9);
805 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();
806 t.refresh().unwrap();
807 assert_eq!(t.summary().turns, 2);
808 assert_eq!(t.summary().activity, Activity::Waiting);
809 let _ = std::fs::remove_dir_all(&dir);
810 }
811
812 #[test]
813 fn keeps_claude_codes_own_cost_beside_ours_and_never_in_it() {
814 let dir = std::env::temp_dir().join(format!("agent-top-claude-cost-{}", std::process::id()));
815 std::fs::create_dir_all(&dir).unwrap();
816 let path = dir.join("s.jsonl");
817 let mut f = std::fs::File::create(&path).unwrap();
818 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-10-01T07:00:01.000Z","sessionId":"abc","message":{{"id":"msg_1","model":"claude-sonnet-5","stop_reason":"end_turn","content":[],"usage":{{"input_tokens":1000000,"output_tokens":0}}}}}}"#).unwrap();
819 let mut t = ClaudeTranscript::new(&path);
820 t.refresh().unwrap();
821 assert_eq!(t.summary().harness_cost, None, "no record, no figure");
822
823 let state = r#"{"type":"cost-state","sessionId":"abc","totalCostUSD":2.5,"hasUnknownModelCost":false,"modelUsage":{}}"#;
825 writeln!(f, "{state}\n{state}").unwrap();
826 t.refresh().unwrap();
827 let s = t.summary();
828 let h = s.harness_cost.expect("read");
829 assert_eq!(h.usd, 2.5);
830 assert!(!h.lower_bound);
831 assert!(h.current, "nothing has run since it was written");
832 assert_eq!(h.as_of, parse_rfc3339_utc("2026-10-01T07:00:01.000Z"), "dated by the line before it");
833 assert!((s.cost_usd - 2.0).abs() < 1e-9, "our figure is still the transcript at list price");
834
835 writeln!(f, r#"{{"type":"cost-state","totalCostUSD":3.75,"hasUnknownModelCost":true}}"#).unwrap();
837 t.refresh().unwrap();
838 let h = t.summary().harness_cost.unwrap();
839 assert_eq!((h.usd, h.lower_bound), (3.75, true));
840
841 let subs = subagents_dir(&path).unwrap();
843 std::fs::create_dir_all(&subs).unwrap();
844 let reply = |ts: &str| {
845 format!(
846 r#"{{"type":"assistant","timestamp":"{ts}","isSidechain":true,"message":{{"id":"m_{ts}","model":"claude-sonnet-5","stop_reason":"end_turn","content":[],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#
847 )
848 };
849 std::fs::write(subs.join("agent-a.jsonl"), reply("2026-10-01T06:59:00.000Z") + "\n").unwrap();
850 t.refresh().unwrap();
851 assert!(t.summary().harness_cost.unwrap().current);
852
853 std::fs::write(subs.join("agent-b.jsonl"), reply("2026-10-01T09:00:00.000Z") + "\n").unwrap();
855 t.refresh().unwrap();
856 assert!(!t.summary().harness_cost.unwrap().current, "the subagent ran after the exit");
857 std::fs::remove_file(subs.join("agent-b.jsonl")).unwrap();
858 let mut t = ClaudeTranscript::new(&path);
859 t.refresh().unwrap();
860 assert!(t.summary().harness_cost.unwrap().current);
861 writeln!(f, "{}", reply("2026-10-01T09:30:00.000Z").replace(r#""isSidechain":true,"#, "")).unwrap();
862 t.refresh().unwrap();
863 let h = t.summary().harness_cost.unwrap();
864 assert!(!h.current, "a resumed session's usage since is not in it");
865 assert_eq!(h.usd, 3.75, "and the figure itself is unchanged until the next exit");
866 let _ = std::fs::remove_dir_all(&dir);
867 }
868
869 #[test]
870 fn counts_calls_per_mcp_server_and_their_errors() {
871 let dir = std::env::temp_dir().join(format!("agent-top-claude-mcp-{}", std::process::id()));
872 std::fs::create_dir_all(&dir).unwrap();
873 let path = dir.join("s.jsonl");
874 let mut f = std::fs::File::create(&path).unwrap();
875 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.000Z","message":{{"id":"m1","model":"claude-sonnet-5","content":[{{"type":"tool_use","id":"t1","name":"mcp__filesystem__read_file"}},{{"type":"tool_use","id":"t2","name":"mcp__chrome-devtools__take_screenshot"}},{{"type":"tool_use","id":"t3","name":"Bash"}}]}}}}"#).unwrap();
876 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:02.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}},{{"type":"tool_result","tool_use_id":"t2","is_error":true}},{{"type":"tool_result","tool_use_id":"t3","is_error":true}}]}}}}"#).unwrap();
877 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:05.000Z","message":{{"id":"m2","model":"claude-sonnet-5","content":[{{"type":"tool_use","id":"t4","name":"mcp__filesystem__list_directory"}}]}}}}"#).unwrap();
878 let mut t = ClaudeTranscript::new(&path);
879 t.refresh().unwrap();
880 let s = t.summary();
881 assert_eq!(s.tool_calls, 4, "MCP calls are tool calls too");
882 assert_eq!(s.mcp.len(), 2, "Bash is not a server");
883 let fs = &s.mcp["filesystem"];
884 assert_eq!((fs.calls, fs.errors), (2, 0));
885 assert_eq!(fs.last_call, parse_rfc3339_utc("2026-09-03T07:00:05.000Z"));
886 let cd = &s.mcp["chrome-devtools"];
887 assert_eq!((cd.calls, cd.errors), (1, 1), "the failed result is charged to its server, not to Bash's");
888 let _ = std::fs::remove_dir_all(&dir);
889 }
890
891 #[test]
892 fn files_context_growth_under_each_tool_and_resets_at_a_compaction() {
893 let dir = std::env::temp_dir().join(format!("agent-top-claude-context-{}", std::process::id()));
894 std::fs::create_dir_all(&dir).unwrap();
895 let path = dir.join("s.jsonl");
896 let mut f = std::fs::File::create(&path).unwrap();
897 let usage = |read: u64, out: u64| format!(r#"{{"cache_read_input_tokens":{read},"output_tokens":{out}}}"#);
898 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"go"}}}}"#).unwrap();
900 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.000Z","message":{{"id":"m1","model":"claude-sonnet-5","content":[{{"type":"tool_use","id":"t1","name":"Read"}},{{"type":"tool_use","id":"t2","name":"mcp__fs__read_text_file"}}],"usage":{}}}}}"#, usage(10_000, 100)).unwrap();
901 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.500Z","message":{{"id":"m1","model":"claude-sonnet-5","content":[{{"type":"text"}}],"usage":{}}}}}"#, usage(10_000, 100)).unwrap();
903 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:02.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}},{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
904 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:03.000Z","message":{{"id":"m2","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{}}}}}"#, usage(14_100, 50)).unwrap();
906 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
907 t.refresh().unwrap();
908 let by_name = |s: &SessionSummary| -> std::collections::HashMap<String, crate::model::ContextSource> {
909 s.context.sources().into_iter().map(|c| (c.name.clone(), c)).collect()
910 };
911 let c = by_name(t.summary());
912 assert_eq!(c["Read"].tokens, 2_000);
913 assert_eq!((c["fs"].tokens, c["fs"].origin, c["fs"].calls), (2_000, ContextOrigin::Mcp, 1));
914 assert_eq!(c["other"].tokens, 10_100);
915 assert!((c["Read"].cost_usd - 2_000.0 * 0.2 / 1e6).abs() < 1e-12, "{}", c["Read"].cost_usd);
918 assert!((c["other"].cost_usd - 20_100.0 * 0.2 / 1e6).abs() < 1e-12, "{}", c["other"].cost_usd);
919 let prompt_cost = t.summary().cost_breakdown.cache_read;
920 let attributed: f64 = c.values().map(|x| x.cost_usd).sum();
921 assert!((attributed - prompt_cost).abs() < 1e-12, "sources sum to the prompt-side cost");
922
923 writeln!(f, r#"{{"type":"system","subtype":"compact_boundary","timestamp":"2026-09-03T07:10:00.000Z","compactMetadata":{{"trigger":"auto","preTokens":14100,"postTokens":3000}}}}"#).unwrap();
926 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:10:01.000Z","isCompactSummary":true,"message":{{"role":"user","content":"summary"}}}}"#).unwrap();
927 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:10:05.000Z","message":{{"id":"m3","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{}}}}}"#, usage(3_000, 10)).unwrap();
928 t.refresh().unwrap();
929 let c = by_name(t.summary());
930 assert_eq!(c["other"].tokens, 13_100);
931 assert!((c["Read"].cost_usd - 2_000.0 * 0.2 / 1e6).abs() < 1e-12, "not charged after the compaction");
932 let _ = std::fs::remove_dir_all(&dir);
933 }
934
935 #[test]
936 fn reconstructs_turns_inferences_and_web_searches() {
937 let dir = std::env::temp_dir().join(format!("agent-top-claude-turns-{}", std::process::id()));
938 std::fs::create_dir_all(&dir).unwrap();
939 let path = dir.join("s.jsonl");
940 let mut f = std::fs::File::create(&path).unwrap();
941 let usage = r#"{"input_tokens":100,"output_tokens":10,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5}}"#;
942 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"look it up"}}}}"#)
944 .unwrap();
945 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:02.000Z","message":{{"id":"m1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"thinking"}}],"usage":{usage}}}}}"#).unwrap();
946 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:04.000Z","message":{{"id":"m1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"t1","name":"Bash"}}],"usage":{usage}}}}}"#).unwrap();
947 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:05.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}}]}}}}"#).unwrap();
949 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:09.000Z","message":{{"id":"m2","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
950 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
951 t.refresh().unwrap();
952 let s = t.summary();
953 assert_eq!(s.web_searches, 2);
955 assert!((s.cost_usd - (0.000300 + 0.02 + 0.000012)).abs() < 1e-9, "{}", s.cost_usd);
957 let by_kind = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).cloned().collect::<Vec<_>>();
958 let turns = by_kind(SpanKind::Turn);
959 assert_eq!(turns.len(), 1);
960 assert_eq!(turns[0].duration_ms, Some(9_000), "prompt at :00, reply ended at :09");
961 let inf = by_kind(SpanKind::Inference);
962 assert_eq!(inf.len(), 2);
963 assert_eq!(inf[0].duration_ms, Some(4_000), "prompt at :00, last block of the reply at :04");
964 assert_eq!(inf[1].duration_ms, Some(4_000), "tool result at :05, reply at :09");
965 assert_eq!(by_kind(SpanKind::Tool)[0].duration_ms, Some(1_000));
966 let kinds: Vec<_> = s.spans.iter().map(|sp| sp.kind).collect();
968 assert_eq!(kinds, vec![SpanKind::Turn, SpanKind::Inference, SpanKind::Tool, SpanKind::Inference]);
969 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:00.000Z","message":{{"role":"user","content":"thanks"}}}}"#).unwrap();
971 t.refresh().unwrap();
972 let turns = by_kind_of(t.summary(), SpanKind::Turn);
973 assert_eq!(turns.len(), 2);
974 assert_eq!(turns[0].duration_ms, Some(9_000));
975 assert!(turns[1].is_open());
976 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:01:05.000Z","message":{{"id":"m3","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"t2","name":"Bash"}}],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
981 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:06.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
982 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T07:00:00.000Z","message":{{"role":"user","content":"next day"}}}}"#)
983 .unwrap();
984 t.refresh().unwrap();
985 let turns = by_kind_of(t.summary(), SpanKind::Turn);
986 assert_eq!(turns.len(), 3);
987 assert_eq!(turns[1].duration_ms, Some(6_000), "turn 2: :01:00 to the interrupted tool result at :01:06");
988 assert!(turns[2].is_open());
989 let inf = by_kind_of(t.summary(), SpanKind::Inference);
990 assert_eq!(inf.iter().filter(|s| s.is_open()).count(), 1, "only the newest inference is open");
991 assert_eq!(inf.last().unwrap().started_at, turns[2].started_at);
992 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-04T07:00:02.000Z","message":{{"id":"m4","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
996 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:00:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
997 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-04T09:00:03.000Z","message":{{"id":"m5","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
998 t.refresh().unwrap();
999 let turns = by_kind_of(t.summary(), SpanKind::Turn);
1000 assert_eq!(turns.len(), 3, "a meta line is not a prompt");
1001 assert_eq!(turns[2].duration_ms, Some(2_000));
1002 let inf = by_kind_of(t.summary(), SpanKind::Inference);
1003 let last_two: Vec<_> = inf.iter().rev().take(2).map(|s| s.duration_ms).collect();
1004 assert_eq!(last_two, vec![Some(3_000), Some(2_000)], "the command's reply is its own 3 s inference");
1005 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:30:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
1008 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-07T09:00:00.000Z","message":{{"id":"synthetic-1","model":"<synthetic>","stop_reason":"stop_sequence","content":[{{"type":"text"}}],"usage":{{"input_tokens":0,"output_tokens":0}}}}}}"#).unwrap();
1009 t.refresh().unwrap();
1010 let inf = by_kind_of(t.summary(), SpanKind::Inference);
1011 assert!(inf.last().unwrap().is_open(), "the meta line's inference has no real reply yet");
1012 assert_eq!(by_kind_of(t.summary(), SpanKind::Turn)[2].duration_ms, Some(2_000));
1013 let _ = std::fs::remove_dir_all(&dir);
1014 }
1015
1016 fn by_kind_of(s: &SessionSummary, k: SpanKind) -> Vec<crate::model::ToolSpan> {
1017 s.spans.iter().filter(|sp| sp.kind == k).cloned().collect()
1018 }
1019
1020 #[test]
1021 fn folds_subagent_transcripts_into_the_parent() {
1022 let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
1023 let _ = std::fs::remove_dir_all(&dir);
1024 std::fs::create_dir_all(&dir).unwrap();
1025 let path = dir.join("s.jsonl");
1026 let mut f = std::fs::File::create(&path).unwrap();
1027 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();
1029 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
1030 t.refresh().unwrap();
1031 assert_eq!(t.summary().usage.total(), 110);
1032 assert_eq!(t.summary().subagent_turns, 0);
1033 assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
1035
1036 let sub = subagents_dir(&path).unwrap();
1038 std::fs::create_dir_all(&sub).unwrap();
1039 let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
1040 std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
1041 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();
1042 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();
1043 t.refresh().unwrap();
1044 let s = t.summary();
1045 assert_eq!(s.usage.total(), 1210);
1046 assert_eq!(s.turns, 2);
1047 assert_eq!(s.subagent_turns, 1);
1048 assert!(s.folds_child_usage, "a child's usage is inside this summary, so the share is worth breaking out");
1049 assert_eq!(s.tool_calls, 2);
1050 assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
1052 assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
1053 assert_eq!(s.session_id.as_deref(), Some("abc"));
1054 let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1055 assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
1056 let spans: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
1057 assert_eq!(spans.len(), 2);
1058 assert_eq!(spans[0].name, "Agent");
1059 assert!(spans[0].is_open());
1060 assert_eq!(spans[1].name, "Grep");
1061 assert!(spans[1].sidechain);
1062 assert_eq!(spans[1].duration_ms, Some(2_000));
1063 let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
1066 assert_eq!(inf.len(), 1);
1067 assert!(inf[0].sidechain);
1068 assert!(inf[0].is_open());
1069
1070 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();
1072 t.refresh().unwrap();
1073 assert_eq!(t.summary().usage.total(), 1212);
1074 assert_eq!(t.summary().subagent_turns, 2);
1075 let _ = std::fs::remove_dir_all(&dir);
1076 }
1077
1078 #[test]
1079 fn builds_spans_from_tool_use_and_tool_result() {
1080 let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
1081 std::fs::create_dir_all(&dir).unwrap();
1082 let path = dir.join("s.jsonl");
1083 let mut f = std::fs::File::create(&path).unwrap();
1084 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();
1085 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();
1087 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();
1089 let mut t = ClaudeTranscript::new(&path);
1090 t.refresh().unwrap();
1091 let all = t.summary().spans.to_vec();
1092 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
1096 assert_eq!(inf.len(), 1);
1097 assert_eq!(inf[0].name, "inference");
1098 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no prompt line, so no turn");
1099 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).cloned().collect();
1100 assert_eq!(spans.len(), 3);
1101 assert_eq!(spans[0].name, "Bash");
1102 assert_eq!(spans[0].duration_ms, Some(2_500));
1103 assert!(!spans[0].error);
1104 assert_eq!(spans[1].name, "Read");
1105 assert_eq!(spans[1].duration_ms, Some(2_500));
1106 assert!(spans[1].error);
1107 assert!(spans[2].is_open());
1108 assert!(spans[2].sidechain);
1109 assert_eq!(t.summary().tool_calls, 3);
1110 let _ = std::fs::remove_dir_all(&dir);
1111 }
1112}