1use super::{
50 AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, RegistryHints, SessionSummary, SessionTracker, SourceStamp, SpanLog,
51 SpanRetention, 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 fn stamp(&self, path: &Path) -> Option<SourceStamp> {
281 let main = SourceStamp::of_file(path)?;
282 let subs = subagents_dir(path).and_then(|d| std::fs::read_dir(d).ok());
283 Some(subs.into_iter().flatten().flatten().filter_map(|e| SourceStamp::of_file(&e.path())).fold(main, SourceStamp::with))
284 }
285}
286
287pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
291 let stem = transcript.file_stem()?;
292 Some(transcript.with_file_name(stem).join("subagents"))
293}
294
295struct Parser {
298 reader: TailReader,
299 summary: SessionSummary,
300 last_msg_id: Option<String>,
302 last_contrib: Contrib,
303 inference: Option<String>,
306 turn: Option<String>,
307 inferences: u64,
308 turns: u64,
309 prev_ts: Option<SystemTime>,
313 inference_ended_by: Option<String>,
318 turn_ended_by: Option<String>,
319 pending_tools: HashMap<String, String>,
323}
324
325#[derive(Debug, Clone, Copy, Default)]
328struct Contrib {
329 usage: TokenUsage,
330 cost: CostBreakdown,
331 unpriced: u64,
332 searches: u64,
333}
334
335impl Parser {
336 fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
337 Parser {
338 reader: TailReader::new(path),
339 summary: SessionSummary { harness: Some(Harness::Claude), folds_child_usage: true, spans: spans.log(), ..Default::default() },
340 last_msg_id: None,
341 last_contrib: Contrib::default(),
342 inference: None,
343 turn: None,
344 inferences: 0,
345 turns: 0,
346 prev_ts: None,
347 inference_ended_by: None,
348 turn_ended_by: None,
349 pending_tools: HashMap::new(),
350 }
351 }
352
353 fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
355 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
356 for l in &lines {
357 self.ingest(l, prices);
358 }
359 Ok((lines.len(), more))
360 }
361
362 fn ingest(&mut self, line: &str, prices: &Table) {
363 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
364 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
365 if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
366 if self.summary.started_at.is_none() {
367 self.summary.started_at = Some(ts);
368 }
369 self.summary.last_activity = Some(ts);
370 }
371 if self.summary.session_id.is_none() {
372 self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
373 }
374 if self.summary.cwd.is_none() {
375 self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
376 }
377 if self.summary.harness_version.is_none() {
378 self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
379 }
380 let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
381 let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
382 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
383 match kind {
384 "assistant" => {
385 self.ingest_assistant(&v, sidechain, ts, prices);
386 if let Some(h) = &mut self.summary.harness_cost {
387 h.current = false;
388 }
389 }
390 "user" if !is_meta => {
391 self.summary.activity = Activity::Working;
393 if let Some(ts) = ts {
394 let answered = self.close_spans(&v, ts);
395 if !answered {
396 self.begin_turn(ts, sidechain);
397 }
398 self.begin_inference(ts, sidechain);
399 }
400 }
401 "user" => {
406 if let Some(ts) = ts {
407 self.begin_inference(ts, sidechain);
408 }
409 }
410 "system" if v.get("subtype").and_then(Value::as_str) == Some("compact_boundary") => {
411 self.summary.context.compacted();
412 }
413 "cost-state" => {
414 if let Some(usd) = v.get("totalCostUSD").and_then(Value::as_f64) {
415 self.summary.harness_cost = Some(HarnessCost {
416 usd,
417 lower_bound: v.get("hasUnknownModelCost").and_then(Value::as_bool).unwrap_or(false),
418 as_of: self.summary.last_activity,
419 current: true,
420 });
421 }
422 }
423 _ => {}
424 }
425 if ts.is_some() {
426 self.prev_ts = ts;
427 }
428 }
429
430 fn begin_turn(&mut self, ts: SystemTime, sidechain: bool) {
434 if let Some(id) = self.turn.take()
435 && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
436 {
437 let ended = self.prev_ts.unwrap_or(ts).min(ts);
438 self.summary.spans.end_at(&id, ended);
439 }
440 self.turns += 1;
441 let id = format!("turn:{}", self.turns);
442 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, sidechain, SpanKind::Turn);
443 self.turn = Some(id);
444 self.turn_ended_by = None;
445 }
446
447 fn begin_inference(&mut self, ts: SystemTime, sidechain: bool) {
452 if let Some(id) = self.inference.take() {
453 self.summary.spans.discard_open(&id);
454 }
455 self.inferences += 1;
456 let id = format!("inference:{}", self.inferences);
457 self.summary.spans.open_kind(id.clone(), "inference".into(), ts, sidechain, SpanKind::Inference);
458 self.inference = Some(id);
459 self.inference_ended_by = None;
460 }
461
462 fn may_extend(ended_by: &mut Option<String>, id: Option<&str>) -> bool {
466 match (ended_by.as_deref(), id) {
467 (None, Some(id)) => {
468 *ended_by = Some(id.to_string());
469 true
470 }
471 (None, None) => true,
472 (Some(e), Some(id)) => e == id,
473 (Some(_), None) => false,
474 }
475 }
476
477 fn close_spans(&mut self, v: &Value, ts: SystemTime) -> bool {
481 let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return false };
482 let mut answered = false;
483 for b in content {
484 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
485 continue;
486 }
487 answered = true;
488 let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
489 let error = b.get("is_error").and_then(Value::as_bool).unwrap_or(false);
490 self.summary.spans.close(id, ts, error);
491 let name = self.pending_tools.remove(id).unwrap_or_else(|| "tool".into());
492 match mcp_server_of(&name) {
493 Some(server) => {
494 if error {
495 self.summary.mcp.entry(server.to_string()).or_default().errors += 1;
496 }
497 self.summary.context.result(id, ContextOrigin::Mcp, server);
498 }
499 None => self.summary.context.result(id, ContextOrigin::Tool, &name),
500 }
501 }
502 answered
503 }
504
505 fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
506 let Some(msg) = v.get("message") else { return };
507 let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
508 let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
509 if !model.is_empty() && model != "<synthetic>" {
510 self.summary.model = Some(model.to_string());
511 }
512 if let Some(content) = msg.get("content").and_then(Value::as_array) {
513 let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
514 for b in calls {
515 self.summary.tool_calls += 1;
516 let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
517 if let Some(server) = mcp_server_of(name) {
518 let u = self.summary.mcp.entry(server.to_string()).or_default();
519 u.calls += 1;
520 u.last_call = u.last_call.max(ts);
521 }
522 if let Some(id) = b.get("id").and_then(Value::as_str) {
523 self.pending_tools.insert(id.to_string(), name.to_string());
524 }
525 if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
526 self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
527 }
528 }
529 }
530 let synthetic = model == "<synthetic>";
535 if let (Some(ts), Some(span), false) = (ts, self.inference.as_deref(), synthetic) {
536 if Self::may_extend(&mut self.inference_ended_by, id.as_deref()) {
537 self.summary.spans.end_at(span, ts);
538 } else {
539 self.inference = None;
540 }
541 }
542 match msg.get("stop_reason").and_then(Value::as_str) {
543 Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
544 self.summary.activity = Activity::Waiting;
545 if let (Some(ts), Some(span), false) = (ts, self.turn.as_deref(), synthetic) {
549 if Self::may_extend(&mut self.turn_ended_by, id.as_deref()) {
550 self.summary.spans.end_at(span, ts);
551 } else {
552 self.turn = None;
553 }
554 }
555 }
556 _ => self.summary.activity = Activity::Working,
557 }
558
559 if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
562 self.summary.health.billable_messages += 1;
563 }
564 let usage = match msg.get("usage") {
565 Some(u) => {
566 let parsed = parse_usage(u);
567 self.summary.health.usage_records += 1;
568 if parsed.total() == 0 {
569 self.summary.health.empty_usage_records += 1;
570 }
571 parsed
572 }
573 None => TokenUsage::default(),
574 };
575 let price = prices.lookup(model);
576 let searches = msg.pointer("/usage/server_tool_use/web_search_requests").and_then(Value::as_u64).unwrap_or(0);
579 let mut cost = price.map(|p| p.breakdown(&usage)).unwrap_or_default();
580 cost.web_search = prices.web_search_cost(searches);
581 let unpriced = if price.is_none() { usage.total() } else { 0 };
582
583 let same_message = id.is_some() && id == self.last_msg_id;
584 if same_message {
585 let c = self.last_contrib;
587 self.summary.usage.sub(&c.usage);
588 self.summary.cost_usd -= c.cost.total();
589 self.summary.cost_breakdown.sub(&c.cost);
590 self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(c.unpriced);
591 self.summary.web_searches = self.summary.web_searches.saturating_sub(c.searches);
592 } else {
593 self.summary.turns += 1;
594 if sidechain {
595 self.summary.subagent_turns += 1;
596 }
597 self.summary.context.response(&usage, &cost);
600 }
601 self.summary.usage.add(&usage);
602 self.summary.cost_usd += cost.total();
603 self.summary.cost_breakdown.add(&cost);
604 self.summary.unpriced_tokens += unpriced;
605 self.summary.web_searches += searches;
606 self.last_msg_id = id;
607 self.last_contrib = Contrib { usage, cost, unpriced, searches };
608 }
609}
610
611pub struct ClaudeTranscript {
622 main: Parser,
623 subagents: BTreeMap<PathBuf, Parser>,
625 prices: &'static Table,
626 retention: SpanRetention,
627 summary: SessionSummary,
630}
631
632impl ClaudeTranscript {
633 pub fn new(path: impl Into<PathBuf>) -> Self {
634 let retention = SpanRetention::Recent;
635 ClaudeTranscript {
636 main: Parser::new(path, retention),
637 subagents: BTreeMap::new(),
638 prices: pricing::table(),
639 retention,
640 summary: SessionSummary { harness: Some(Harness::Claude), folds_child_usage: true, ..Default::default() },
641 }
642 }
643
644 pub fn with_prices(mut self, prices: &'static Table) -> Self {
647 self.prices = prices;
648 self
649 }
650
651 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
653 self.retention = retention;
654 self.main.summary.spans = retention.log();
655 for p in self.subagents.values_mut() {
656 p.summary.spans = retention.log();
657 }
658 self
659 }
660
661 pub fn set_registry_hints(&mut self, ps: &PidSession) {
662 let s = &mut self.main.summary;
663 s.session_id.get_or_insert_with(|| ps.session_id.clone());
664 s.cwd.get_or_insert_with(|| ps.cwd.clone());
665 if ps.version.is_some() {
666 s.harness_version = ps.version.clone();
667 }
668 if s.started_at.is_none() {
669 s.started_at = ps.started();
670 }
671 self.fold();
672 }
673
674 fn discover_subagents(&mut self) {
678 let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
679 let Ok(rd) = std::fs::read_dir(&dir) else { return };
680 for e in rd.flatten() {
681 let p = e.path();
682 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
683 continue;
684 }
685 let parser = Parser::new(&p, self.retention);
686 self.subagents.insert(p, parser);
687 }
688 }
689
690 fn fold(&mut self) {
691 let mut s = self.main.summary.clone();
692 for c in self.subagents.values() {
693 let t = &c.summary;
694 s.usage.add(&t.usage);
695 s.cost_usd += t.cost_usd;
696 s.cost_breakdown.add(&t.cost_breakdown);
697 s.unpriced_tokens += t.unpriced_tokens;
698 s.turns += t.turns;
699 s.subagent_turns += t.subagent_turns;
700 s.tool_calls += t.tool_calls;
701 s.web_searches += t.web_searches;
702 s.health.billable_messages += t.health.billable_messages;
703 s.health.usage_records += t.health.usage_records;
704 s.health.empty_usage_records += t.health.empty_usage_records;
705 s.last_activity = s.last_activity.max(t.last_activity);
706 for (server, u) in &t.mcp {
707 s.mcp.entry(server.clone()).or_default().add(u);
708 }
709 s.context.merge(&t.context);
710 if let Some(h) = &mut s.harness_cost
712 && t.last_activity > h.as_of
713 && t.health.usage_records > 0
714 {
715 h.current = false;
716 }
717 }
718 if !self.subagents.is_empty() {
719 let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
720 s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
721 }
722 self.summary = s;
723 }
724}
725
726fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
727 matches!((a, b), (Some(x), Some(y)) if x == y)
728}
729
730fn parse_usage(u: &Value) -> TokenUsage {
731 let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
732 let cache_write_total = g("cache_creation_input_tokens");
733 let (w1h, w5m) = match u.get("cache_creation") {
734 Some(cc) => (
735 cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
736 cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
737 ),
738 None => (0, 0),
739 };
740 let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
742 TokenUsage {
743 input: g("input_tokens"),
744 cache_write_5m: w5m,
745 cache_write_1h: w1h,
746 cache_write_unsplit: 0,
748 cache_read: g("cache_read_input_tokens"),
749 output: g("output_tokens"),
750 }
751}
752
753impl SessionTracker for ClaudeTranscript {
754 fn refresh(&mut self) -> anyhow::Result<bool> {
755 let (mut ingested, mut more) = self.main.refresh(self.prices)?;
756 self.discover_subagents();
757 for c in self.subagents.values_mut() {
758 if let Ok((n, m)) = c.refresh(self.prices) {
760 ingested += n;
761 more |= m;
762 }
763 }
764 if ingested > 0 || self.summary.session_id.is_none() {
765 self.fold();
766 }
767 Ok(more)
768 }
769
770 fn summary(&self) -> &SessionSummary {
771 &self.summary
772 }
773
774 fn path(&self) -> &Path {
775 self.main.reader.path()
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782 use std::io::Write;
783
784 #[test]
785 fn encodes_paths_like_claude_code() {
786 assert_eq!(
787 encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
788 "-Users-atlas-Documents-orbital-forge-agent-top"
789 );
790 assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
791 }
792
793 #[test]
794 fn dedupes_usage_by_message_id_and_tracks_state() {
795 let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
796 std::fs::create_dir_all(&dir).unwrap();
797 let path = dir.join("s.jsonl");
798 let mut f = std::fs::File::create(&path).unwrap();
799 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}}"#;
800 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
801 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();
802 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();
803 let mut t = ClaudeTranscript::new(&path);
804 t.refresh().unwrap();
805 let s = t.summary();
806 assert_eq!(s.turns, 1);
807 assert_eq!(s.tool_calls, 1);
808 assert_eq!(s.usage.total(), 1152);
809 assert_eq!(s.activity, Activity::Working);
810 assert_eq!(s.session_id.as_deref(), Some("abc"));
811 assert!((s.cost_usd - 0.001104).abs() < 1e-9);
813 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();
814 t.refresh().unwrap();
815 assert_eq!(t.summary().turns, 2);
816 assert_eq!(t.summary().activity, Activity::Waiting);
817 let _ = std::fs::remove_dir_all(&dir);
818 }
819
820 #[test]
821 fn keeps_claude_codes_own_cost_beside_ours_and_never_in_it() {
822 let dir = std::env::temp_dir().join(format!("agent-top-claude-cost-{}", std::process::id()));
823 std::fs::create_dir_all(&dir).unwrap();
824 let path = dir.join("s.jsonl");
825 let mut f = std::fs::File::create(&path).unwrap();
826 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();
827 let mut t = ClaudeTranscript::new(&path);
828 t.refresh().unwrap();
829 assert_eq!(t.summary().harness_cost, None, "no record, no figure");
830
831 let state = r#"{"type":"cost-state","sessionId":"abc","totalCostUSD":2.5,"hasUnknownModelCost":false,"modelUsage":{}}"#;
833 writeln!(f, "{state}\n{state}").unwrap();
834 t.refresh().unwrap();
835 let s = t.summary();
836 let h = s.harness_cost.expect("read");
837 assert_eq!(h.usd, 2.5);
838 assert!(!h.lower_bound);
839 assert!(h.current, "nothing has run since it was written");
840 assert_eq!(h.as_of, parse_rfc3339_utc("2026-10-01T07:00:01.000Z"), "dated by the line before it");
841 assert!((s.cost_usd - 2.0).abs() < 1e-9, "our figure is still the transcript at list price");
842
843 writeln!(f, r#"{{"type":"cost-state","totalCostUSD":3.75,"hasUnknownModelCost":true}}"#).unwrap();
845 t.refresh().unwrap();
846 let h = t.summary().harness_cost.unwrap();
847 assert_eq!((h.usd, h.lower_bound), (3.75, true));
848
849 let subs = subagents_dir(&path).unwrap();
851 std::fs::create_dir_all(&subs).unwrap();
852 let reply = |ts: &str| {
853 format!(
854 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}}}}}}"#
855 )
856 };
857 std::fs::write(subs.join("agent-a.jsonl"), reply("2026-10-01T06:59:00.000Z") + "\n").unwrap();
858 t.refresh().unwrap();
859 assert!(t.summary().harness_cost.unwrap().current);
860
861 std::fs::write(subs.join("agent-b.jsonl"), reply("2026-10-01T09:00:00.000Z") + "\n").unwrap();
863 t.refresh().unwrap();
864 assert!(!t.summary().harness_cost.unwrap().current, "the subagent ran after the exit");
865 std::fs::remove_file(subs.join("agent-b.jsonl")).unwrap();
866 let mut t = ClaudeTranscript::new(&path);
867 t.refresh().unwrap();
868 assert!(t.summary().harness_cost.unwrap().current);
869 writeln!(f, "{}", reply("2026-10-01T09:30:00.000Z").replace(r#""isSidechain":true,"#, "")).unwrap();
870 t.refresh().unwrap();
871 let h = t.summary().harness_cost.unwrap();
872 assert!(!h.current, "a resumed session's usage since is not in it");
873 assert_eq!(h.usd, 3.75, "and the figure itself is unchanged until the next exit");
874 let _ = std::fs::remove_dir_all(&dir);
875 }
876
877 #[test]
878 fn counts_calls_per_mcp_server_and_their_errors() {
879 let dir = std::env::temp_dir().join(format!("agent-top-claude-mcp-{}", std::process::id()));
880 std::fs::create_dir_all(&dir).unwrap();
881 let path = dir.join("s.jsonl");
882 let mut f = std::fs::File::create(&path).unwrap();
883 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();
884 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();
885 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();
886 let mut t = ClaudeTranscript::new(&path);
887 t.refresh().unwrap();
888 let s = t.summary();
889 assert_eq!(s.tool_calls, 4, "MCP calls are tool calls too");
890 assert_eq!(s.mcp.len(), 2, "Bash is not a server");
891 let fs = &s.mcp["filesystem"];
892 assert_eq!((fs.calls, fs.errors), (2, 0));
893 assert_eq!(fs.last_call, parse_rfc3339_utc("2026-09-03T07:00:05.000Z"));
894 let cd = &s.mcp["chrome-devtools"];
895 assert_eq!((cd.calls, cd.errors), (1, 1), "the failed result is charged to its server, not to Bash's");
896 let _ = std::fs::remove_dir_all(&dir);
897 }
898
899 #[test]
900 fn files_context_growth_under_each_tool_and_resets_at_a_compaction() {
901 let dir = std::env::temp_dir().join(format!("agent-top-claude-context-{}", std::process::id()));
902 std::fs::create_dir_all(&dir).unwrap();
903 let path = dir.join("s.jsonl");
904 let mut f = std::fs::File::create(&path).unwrap();
905 let usage = |read: u64, out: u64| format!(r#"{{"cache_read_input_tokens":{read},"output_tokens":{out}}}"#);
906 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"go"}}}}"#).unwrap();
908 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();
909 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();
911 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();
912 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();
914 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
915 t.refresh().unwrap();
916 let by_name = |s: &SessionSummary| -> std::collections::HashMap<String, crate::model::ContextSource> {
917 s.context.sources().into_iter().map(|c| (c.name.clone(), c)).collect()
918 };
919 let c = by_name(t.summary());
920 assert_eq!(c["Read"].tokens, 2_000);
921 assert_eq!((c["fs"].tokens, c["fs"].origin, c["fs"].calls), (2_000, ContextOrigin::Mcp, 1));
922 assert_eq!(c["other"].tokens, 10_100);
923 assert!((c["Read"].cost_usd - 2_000.0 * 0.2 / 1e6).abs() < 1e-12, "{}", c["Read"].cost_usd);
926 assert!((c["other"].cost_usd - 20_100.0 * 0.2 / 1e6).abs() < 1e-12, "{}", c["other"].cost_usd);
927 let prompt_cost = t.summary().cost_breakdown.cache_read;
928 let attributed: f64 = c.values().map(|x| x.cost_usd).sum();
929 assert!((attributed - prompt_cost).abs() < 1e-12, "sources sum to the prompt-side cost");
930
931 writeln!(f, r#"{{"type":"system","subtype":"compact_boundary","timestamp":"2026-09-03T07:10:00.000Z","compactMetadata":{{"trigger":"auto","preTokens":14100,"postTokens":3000}}}}"#).unwrap();
934 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:10:01.000Z","isCompactSummary":true,"message":{{"role":"user","content":"summary"}}}}"#).unwrap();
935 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();
936 t.refresh().unwrap();
937 let c = by_name(t.summary());
938 assert_eq!(c["other"].tokens, 13_100);
939 assert!((c["Read"].cost_usd - 2_000.0 * 0.2 / 1e6).abs() < 1e-12, "not charged after the compaction");
940 let _ = std::fs::remove_dir_all(&dir);
941 }
942
943 #[test]
944 fn reconstructs_turns_inferences_and_web_searches() {
945 let dir = std::env::temp_dir().join(format!("agent-top-claude-turns-{}", std::process::id()));
946 std::fs::create_dir_all(&dir).unwrap();
947 let path = dir.join("s.jsonl");
948 let mut f = std::fs::File::create(&path).unwrap();
949 let usage = r#"{"input_tokens":100,"output_tokens":10,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5}}"#;
950 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"look it up"}}}}"#)
952 .unwrap();
953 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();
954 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();
955 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:05.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}}]}}}}"#).unwrap();
957 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();
958 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
959 t.refresh().unwrap();
960 let s = t.summary();
961 assert_eq!(s.web_searches, 2);
963 assert!((s.cost_usd - (0.000300 + 0.02 + 0.000012)).abs() < 1e-9, "{}", s.cost_usd);
965 let by_kind = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).cloned().collect::<Vec<_>>();
966 let turns = by_kind(SpanKind::Turn);
967 assert_eq!(turns.len(), 1);
968 assert_eq!(turns[0].duration_ms, Some(9_000), "prompt at :00, reply ended at :09");
969 let inf = by_kind(SpanKind::Inference);
970 assert_eq!(inf.len(), 2);
971 assert_eq!(inf[0].duration_ms, Some(4_000), "prompt at :00, last block of the reply at :04");
972 assert_eq!(inf[1].duration_ms, Some(4_000), "tool result at :05, reply at :09");
973 assert_eq!(by_kind(SpanKind::Tool)[0].duration_ms, Some(1_000));
974 let kinds: Vec<_> = s.spans.iter().map(|sp| sp.kind).collect();
976 assert_eq!(kinds, vec![SpanKind::Turn, SpanKind::Inference, SpanKind::Tool, SpanKind::Inference]);
977 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:00.000Z","message":{{"role":"user","content":"thanks"}}}}"#).unwrap();
979 t.refresh().unwrap();
980 let turns = by_kind_of(t.summary(), SpanKind::Turn);
981 assert_eq!(turns.len(), 2);
982 assert_eq!(turns[0].duration_ms, Some(9_000));
983 assert!(turns[1].is_open());
984 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();
989 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:06.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
990 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T07:00:00.000Z","message":{{"role":"user","content":"next day"}}}}"#)
991 .unwrap();
992 t.refresh().unwrap();
993 let turns = by_kind_of(t.summary(), SpanKind::Turn);
994 assert_eq!(turns.len(), 3);
995 assert_eq!(turns[1].duration_ms, Some(6_000), "turn 2: :01:00 to the interrupted tool result at :01:06");
996 assert!(turns[2].is_open());
997 let inf = by_kind_of(t.summary(), SpanKind::Inference);
998 assert_eq!(inf.iter().filter(|s| s.is_open()).count(), 1, "only the newest inference is open");
999 assert_eq!(inf.last().unwrap().started_at, turns[2].started_at);
1000 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();
1004 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:00:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
1005 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();
1006 t.refresh().unwrap();
1007 let turns = by_kind_of(t.summary(), SpanKind::Turn);
1008 assert_eq!(turns.len(), 3, "a meta line is not a prompt");
1009 assert_eq!(turns[2].duration_ms, Some(2_000));
1010 let inf = by_kind_of(t.summary(), SpanKind::Inference);
1011 let last_two: Vec<_> = inf.iter().rev().take(2).map(|s| s.duration_ms).collect();
1012 assert_eq!(last_two, vec![Some(3_000), Some(2_000)], "the command's reply is its own 3 s inference");
1013 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:30:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
1016 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();
1017 t.refresh().unwrap();
1018 let inf = by_kind_of(t.summary(), SpanKind::Inference);
1019 assert!(inf.last().unwrap().is_open(), "the meta line's inference has no real reply yet");
1020 assert_eq!(by_kind_of(t.summary(), SpanKind::Turn)[2].duration_ms, Some(2_000));
1021 let _ = std::fs::remove_dir_all(&dir);
1022 }
1023
1024 fn by_kind_of(s: &SessionSummary, k: SpanKind) -> Vec<crate::model::ToolSpan> {
1025 s.spans.iter().filter(|sp| sp.kind == k).cloned().collect()
1026 }
1027
1028 #[test]
1029 fn folds_subagent_transcripts_into_the_parent() {
1030 let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
1031 let _ = std::fs::remove_dir_all(&dir);
1032 std::fs::create_dir_all(&dir).unwrap();
1033 let path = dir.join("s.jsonl");
1034 let mut f = std::fs::File::create(&path).unwrap();
1035 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();
1037 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
1038 t.refresh().unwrap();
1039 assert_eq!(t.summary().usage.total(), 110);
1040 assert_eq!(t.summary().subagent_turns, 0);
1041 assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
1043
1044 let sub = subagents_dir(&path).unwrap();
1046 std::fs::create_dir_all(&sub).unwrap();
1047 let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
1048 std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
1049 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();
1050 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();
1051 t.refresh().unwrap();
1052 let s = t.summary();
1053 assert_eq!(s.usage.total(), 1210);
1054 assert_eq!(s.turns, 2);
1055 assert_eq!(s.subagent_turns, 1);
1056 assert!(s.folds_child_usage, "a child's usage is inside this summary, so the share is worth breaking out");
1057 assert_eq!(s.tool_calls, 2);
1058 assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
1060 assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
1061 assert_eq!(s.session_id.as_deref(), Some("abc"));
1062 let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1063 assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
1064 let spans: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
1065 assert_eq!(spans.len(), 2);
1066 assert_eq!(spans[0].name, "Agent");
1067 assert!(spans[0].is_open());
1068 assert_eq!(spans[1].name, "Grep");
1069 assert!(spans[1].sidechain);
1070 assert_eq!(spans[1].duration_ms, Some(2_000));
1071 let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
1074 assert_eq!(inf.len(), 1);
1075 assert!(inf[0].sidechain);
1076 assert!(inf[0].is_open());
1077
1078 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();
1080 t.refresh().unwrap();
1081 assert_eq!(t.summary().usage.total(), 1212);
1082 assert_eq!(t.summary().subagent_turns, 2);
1083 let _ = std::fs::remove_dir_all(&dir);
1084 }
1085
1086 #[test]
1087 fn builds_spans_from_tool_use_and_tool_result() {
1088 let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
1089 std::fs::create_dir_all(&dir).unwrap();
1090 let path = dir.join("s.jsonl");
1091 let mut f = std::fs::File::create(&path).unwrap();
1092 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();
1093 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();
1095 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();
1097 let mut t = ClaudeTranscript::new(&path);
1098 t.refresh().unwrap();
1099 let all = t.summary().spans.to_vec();
1100 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
1104 assert_eq!(inf.len(), 1);
1105 assert_eq!(inf[0].name, "inference");
1106 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no prompt line, so no turn");
1107 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).cloned().collect();
1108 assert_eq!(spans.len(), 3);
1109 assert_eq!(spans[0].name, "Bash");
1110 assert_eq!(spans[0].duration_ms, Some(2_500));
1111 assert!(!spans[0].error);
1112 assert_eq!(spans[1].name, "Read");
1113 assert_eq!(spans[1].duration_ms, Some(2_500));
1114 assert!(spans[1].error);
1115 assert!(spans[2].is_open());
1116 assert!(spans[2].sidechain);
1117 assert_eq!(t.summary().tool_calls, 3);
1118 let _ = std::fs::remove_dir_all(&dir);
1119 }
1120}