1use super::{REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanLog, SpanRetention, parse_rfc3339_utc};
34use crate::jsonl::TailReader;
35use crate::model::{Activity, CostBreakdown, Harness, SpanKind, TokenUsage};
36use crate::pricing::{self, Table};
37use serde::Deserialize;
38use serde_json::Value;
39use std::collections::BTreeMap;
40use std::path::{Path, PathBuf};
41use std::time::{Duration, SystemTime, UNIX_EPOCH};
42
43pub fn home() -> Option<PathBuf> {
44 std::env::var_os("HOME").map(PathBuf::from)
45}
46
47pub fn claude_dir() -> Option<PathBuf> {
48 if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
49 return Some(PathBuf::from(d));
50 }
51 home().map(|h| h.join(".claude"))
52}
53
54pub fn sessions_dir() -> Option<PathBuf> {
55 claude_dir().map(|d| d.join("sessions"))
56}
57
58pub fn projects_dir() -> Option<PathBuf> {
59 claude_dir().map(|d| d.join("projects"))
60}
61
62pub fn encode_project_path(p: &Path) -> String {
65 p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
66}
67
68pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
69 projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
70}
71
72#[derive(Debug, Clone, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct PidSession {
76 pub pid: u32,
77 pub session_id: String,
78 pub cwd: PathBuf,
79 #[serde(default)]
80 pub name: Option<String>,
81 #[serde(default)]
82 pub status: Option<String>,
83 #[serde(default)]
84 pub version: Option<String>,
85 #[serde(default)]
86 pub kind: Option<String>,
87 #[serde(default)]
88 pub entrypoint: Option<String>,
89 #[serde(default)]
90 pub started_at: Option<u64>,
91 #[serde(default)]
92 pub updated_at: Option<u64>,
93}
94
95impl PidSession {
96 pub fn started(&self) -> Option<SystemTime> {
97 self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
98 }
99}
100
101pub fn read_pid_sessions() -> Vec<PidSession> {
104 let Some(dir) = sessions_dir() else { return Vec::new() };
105 let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
106 let mut out = Vec::new();
107 for e in rd.flatten() {
108 let p = e.path();
109 if p.extension().and_then(|x| x.to_str()) != Some("json") {
110 continue;
111 }
112 if let Ok(s) = std::fs::read_to_string(&p)
113 && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
114 {
115 out.push(ps);
116 }
117 }
118 out
119}
120
121pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
123 let Some(dir) = projects_dir() else { return Vec::new() };
124 let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
125 let mut out = Vec::new();
126 for proj in projects.flatten() {
127 let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
128 for f in files.flatten() {
129 let p = f.path();
130 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
131 continue;
132 }
133 if let Ok(md) = f.metadata()
134 && md.modified().map(|m| m >= since).unwrap_or(false)
135 {
136 out.push(p);
137 }
138 }
139 }
140 out
141}
142
143pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
146 let dir = projects_dir()?.join(encode_project_path(cwd));
147 let rd = std::fs::read_dir(&dir).ok()?;
148 let slack = Duration::from_secs(15);
149 let mut best: Option<(Duration, PathBuf)> = None;
150 for f in rd.flatten() {
151 let p = f.path();
152 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
153 continue;
154 }
155 let Ok(md) = f.metadata() else { continue };
158 let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
159 if created + slack < proc_start {
160 continue;
161 }
162 let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
163 if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
164 best = Some((gap, p));
165 }
166 }
167 best.map(|(_, p)| p)
168}
169
170pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
174 let stem = transcript.file_stem()?;
175 Some(transcript.with_file_name(stem).join("subagents"))
176}
177
178struct Parser {
181 reader: TailReader,
182 summary: SessionSummary,
183 last_msg_id: Option<String>,
185 last_contrib: Contrib,
186 inference: Option<String>,
189 turn: Option<String>,
190 inferences: u64,
191 turns: u64,
192 prev_ts: Option<SystemTime>,
196 inference_ended_by: Option<String>,
201 turn_ended_by: Option<String>,
202}
203
204#[derive(Debug, Clone, Copy, Default)]
207struct Contrib {
208 usage: TokenUsage,
209 cost: CostBreakdown,
210 unpriced: u64,
211 searches: u64,
212}
213
214impl Parser {
215 fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
216 Parser {
217 reader: TailReader::new(path),
218 summary: SessionSummary { harness: Some(Harness::Claude), spans: spans.log(), ..Default::default() },
219 last_msg_id: None,
220 last_contrib: Contrib::default(),
221 inference: None,
222 turn: None,
223 inferences: 0,
224 turns: 0,
225 prev_ts: None,
226 inference_ended_by: None,
227 turn_ended_by: None,
228 }
229 }
230
231 fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
233 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
234 for l in &lines {
235 self.ingest(l, prices);
236 }
237 Ok((lines.len(), more))
238 }
239
240 fn ingest(&mut self, line: &str, prices: &Table) {
241 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
242 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
243 if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
244 if self.summary.started_at.is_none() {
245 self.summary.started_at = Some(ts);
246 }
247 self.summary.last_activity = Some(ts);
248 }
249 if self.summary.session_id.is_none() {
250 self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
251 }
252 if self.summary.cwd.is_none() {
253 self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
254 }
255 if self.summary.harness_version.is_none() {
256 self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
257 }
258 let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
259 let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
260 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
261 match kind {
262 "assistant" => self.ingest_assistant(&v, sidechain, ts, prices),
263 "user" if !is_meta => {
264 self.summary.activity = Activity::Working;
266 if let Some(ts) = ts {
267 let answered = self.close_spans(&v, ts);
268 if !answered {
269 self.begin_turn(ts, sidechain);
270 }
271 self.begin_inference(ts, sidechain);
272 }
273 }
274 "user" => {
279 if let Some(ts) = ts {
280 self.begin_inference(ts, sidechain);
281 }
282 }
283 _ => {}
284 }
285 if ts.is_some() {
286 self.prev_ts = ts;
287 }
288 }
289
290 fn begin_turn(&mut self, ts: SystemTime, sidechain: bool) {
294 if let Some(id) = self.turn.take()
295 && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
296 {
297 let ended = self.prev_ts.unwrap_or(ts).min(ts);
298 self.summary.spans.end_at(&id, ended);
299 }
300 self.turns += 1;
301 let id = format!("turn:{}", self.turns);
302 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, sidechain, SpanKind::Turn);
303 self.turn = Some(id);
304 self.turn_ended_by = None;
305 }
306
307 fn begin_inference(&mut self, ts: SystemTime, sidechain: bool) {
312 if let Some(id) = self.inference.take() {
313 self.summary.spans.discard_open(&id);
314 }
315 self.inferences += 1;
316 let id = format!("inference:{}", self.inferences);
317 self.summary.spans.open_kind(id.clone(), "inference".into(), ts, sidechain, SpanKind::Inference);
318 self.inference = Some(id);
319 self.inference_ended_by = None;
320 }
321
322 fn may_extend(ended_by: &mut Option<String>, id: Option<&str>) -> bool {
326 match (ended_by.as_deref(), id) {
327 (None, Some(id)) => {
328 *ended_by = Some(id.to_string());
329 true
330 }
331 (None, None) => true,
332 (Some(e), Some(id)) => e == id,
333 (Some(_), None) => false,
334 }
335 }
336
337 fn close_spans(&mut self, v: &Value, ts: SystemTime) -> bool {
341 let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return false };
342 let mut answered = false;
343 for b in content {
344 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
345 continue;
346 }
347 answered = true;
348 let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
349 self.summary.spans.close(id, ts, b.get("is_error").and_then(Value::as_bool).unwrap_or(false));
350 }
351 answered
352 }
353
354 fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
355 let Some(msg) = v.get("message") else { return };
356 let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
357 let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
358 if !model.is_empty() && model != "<synthetic>" {
359 self.summary.model = Some(model.to_string());
360 }
361 if let Some(content) = msg.get("content").and_then(Value::as_array) {
362 let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
363 for b in calls {
364 self.summary.tool_calls += 1;
365 if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
366 let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
367 self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
368 }
369 }
370 }
371 let synthetic = model == "<synthetic>";
376 if let (Some(ts), Some(span), false) = (ts, self.inference.as_deref(), synthetic) {
377 if Self::may_extend(&mut self.inference_ended_by, id.as_deref()) {
378 self.summary.spans.end_at(span, ts);
379 } else {
380 self.inference = None;
381 }
382 }
383 match msg.get("stop_reason").and_then(Value::as_str) {
384 Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
385 self.summary.activity = Activity::Waiting;
386 if let (Some(ts), Some(span), false) = (ts, self.turn.as_deref(), synthetic) {
390 if Self::may_extend(&mut self.turn_ended_by, id.as_deref()) {
391 self.summary.spans.end_at(span, ts);
392 } else {
393 self.turn = None;
394 }
395 }
396 }
397 _ => self.summary.activity = Activity::Working,
398 }
399
400 if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
403 self.summary.health.billable_messages += 1;
404 }
405 let usage = match msg.get("usage") {
406 Some(u) => {
407 let parsed = parse_usage(u);
408 self.summary.health.usage_records += 1;
409 if parsed.total() == 0 {
410 self.summary.health.empty_usage_records += 1;
411 }
412 parsed
413 }
414 None => TokenUsage::default(),
415 };
416 let price = prices.lookup(model);
417 let searches = msg.pointer("/usage/server_tool_use/web_search_requests").and_then(Value::as_u64).unwrap_or(0);
420 let mut cost = price.map(|p| p.breakdown(&usage)).unwrap_or_default();
421 cost.web_search = prices.web_search_cost(searches);
422 let unpriced = if price.is_none() { usage.total() } else { 0 };
423
424 let same_message = id.is_some() && id == self.last_msg_id;
425 if same_message {
426 let c = self.last_contrib;
428 self.summary.usage.sub(&c.usage);
429 self.summary.cost_usd -= c.cost.total();
430 self.summary.cost_breakdown.sub(&c.cost);
431 self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(c.unpriced);
432 self.summary.web_searches = self.summary.web_searches.saturating_sub(c.searches);
433 } else {
434 self.summary.turns += 1;
435 if sidechain {
436 self.summary.subagent_turns += 1;
437 }
438 }
439 self.summary.usage.add(&usage);
440 self.summary.cost_usd += cost.total();
441 self.summary.cost_breakdown.add(&cost);
442 self.summary.unpriced_tokens += unpriced;
443 self.summary.web_searches += searches;
444 self.last_msg_id = id;
445 self.last_contrib = Contrib { usage, cost, unpriced, searches };
446 }
447}
448
449pub struct ClaudeTranscript {
460 main: Parser,
461 subagents: BTreeMap<PathBuf, Parser>,
463 prices: &'static Table,
464 retention: SpanRetention,
465 summary: SessionSummary,
468}
469
470impl ClaudeTranscript {
471 pub fn new(path: impl Into<PathBuf>) -> Self {
472 let retention = SpanRetention::Recent;
473 ClaudeTranscript {
474 main: Parser::new(path, retention),
475 subagents: BTreeMap::new(),
476 prices: pricing::table(),
477 retention,
478 summary: SessionSummary { harness: Some(Harness::Claude), ..Default::default() },
479 }
480 }
481
482 pub fn with_prices(mut self, prices: &'static Table) -> Self {
485 self.prices = prices;
486 self
487 }
488
489 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
491 self.retention = retention;
492 self.main.summary.spans = retention.log();
493 for p in self.subagents.values_mut() {
494 p.summary.spans = retention.log();
495 }
496 self
497 }
498
499 pub fn set_registry_hints(&mut self, ps: &PidSession) {
500 let s = &mut self.main.summary;
501 s.session_id.get_or_insert_with(|| ps.session_id.clone());
502 s.cwd.get_or_insert_with(|| ps.cwd.clone());
503 if ps.version.is_some() {
504 s.harness_version = ps.version.clone();
505 }
506 if s.started_at.is_none() {
507 s.started_at = ps.started();
508 }
509 self.fold();
510 }
511
512 fn discover_subagents(&mut self) {
516 let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
517 let Ok(rd) = std::fs::read_dir(&dir) else { return };
518 for e in rd.flatten() {
519 let p = e.path();
520 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
521 continue;
522 }
523 let parser = Parser::new(&p, self.retention);
524 self.subagents.insert(p, parser);
525 }
526 }
527
528 fn fold(&mut self) {
529 let mut s = self.main.summary.clone();
530 for c in self.subagents.values() {
531 let t = &c.summary;
532 s.usage.add(&t.usage);
533 s.cost_usd += t.cost_usd;
534 s.cost_breakdown.add(&t.cost_breakdown);
535 s.unpriced_tokens += t.unpriced_tokens;
536 s.turns += t.turns;
537 s.subagent_turns += t.subagent_turns;
538 s.tool_calls += t.tool_calls;
539 s.web_searches += t.web_searches;
540 s.health.billable_messages += t.health.billable_messages;
541 s.health.usage_records += t.health.usage_records;
542 s.health.empty_usage_records += t.health.empty_usage_records;
543 s.last_activity = s.last_activity.max(t.last_activity);
544 }
545 if !self.subagents.is_empty() {
546 let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
547 s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
548 }
549 self.summary = s;
550 }
551}
552
553fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
554 matches!((a, b), (Some(x), Some(y)) if x == y)
555}
556
557fn parse_usage(u: &Value) -> TokenUsage {
558 let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
559 let cache_write_total = g("cache_creation_input_tokens");
560 let (w1h, w5m) = match u.get("cache_creation") {
561 Some(cc) => (
562 cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
563 cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
564 ),
565 None => (0, 0),
566 };
567 let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
569 TokenUsage {
570 input: g("input_tokens"),
571 cache_write_5m: w5m,
572 cache_write_1h: w1h,
573 cache_read: g("cache_read_input_tokens"),
574 output: g("output_tokens"),
575 }
576}
577
578impl SessionTracker for ClaudeTranscript {
579 fn refresh(&mut self) -> anyhow::Result<bool> {
580 let (mut ingested, mut more) = self.main.refresh(self.prices)?;
581 self.discover_subagents();
582 for c in self.subagents.values_mut() {
583 if let Ok((n, m)) = c.refresh(self.prices) {
585 ingested += n;
586 more |= m;
587 }
588 }
589 if ingested > 0 || self.summary.session_id.is_none() {
590 self.fold();
591 }
592 Ok(more)
593 }
594
595 fn summary(&self) -> &SessionSummary {
596 &self.summary
597 }
598
599 fn path(&self) -> &Path {
600 self.main.reader.path()
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use std::io::Write;
608
609 #[test]
610 fn encodes_paths_like_claude_code() {
611 assert_eq!(
612 encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
613 "-Users-atlas-Documents-orbital-forge-agent-top"
614 );
615 assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
616 }
617
618 #[test]
619 fn dedupes_usage_by_message_id_and_tracks_state() {
620 let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
621 std::fs::create_dir_all(&dir).unwrap();
622 let path = dir.join("s.jsonl");
623 let mut f = std::fs::File::create(&path).unwrap();
624 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}}"#;
625 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
626 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();
627 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();
628 let mut t = ClaudeTranscript::new(&path);
629 t.refresh().unwrap();
630 let s = t.summary();
631 assert_eq!(s.turns, 1);
632 assert_eq!(s.tool_calls, 1);
633 assert_eq!(s.usage.total(), 1152);
634 assert_eq!(s.activity, Activity::Working);
635 assert_eq!(s.session_id.as_deref(), Some("abc"));
636 assert!((s.cost_usd - 0.001104).abs() < 1e-9);
638 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();
639 t.refresh().unwrap();
640 assert_eq!(t.summary().turns, 2);
641 assert_eq!(t.summary().activity, Activity::Waiting);
642 let _ = std::fs::remove_dir_all(&dir);
643 }
644
645 #[test]
646 fn reconstructs_turns_inferences_and_web_searches() {
647 let dir = std::env::temp_dir().join(format!("agent-top-claude-turns-{}", std::process::id()));
648 std::fs::create_dir_all(&dir).unwrap();
649 let path = dir.join("s.jsonl");
650 let mut f = std::fs::File::create(&path).unwrap();
651 let usage = r#"{"input_tokens":100,"output_tokens":10,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5}}"#;
652 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"look it up"}}}}"#)
654 .unwrap();
655 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();
656 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();
657 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:05.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}}]}}}}"#).unwrap();
659 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();
660 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
661 t.refresh().unwrap();
662 let s = t.summary();
663 assert_eq!(s.web_searches, 2);
665 assert!((s.cost_usd - (0.000300 + 0.02 + 0.000012)).abs() < 1e-9, "{}", s.cost_usd);
667 let by_kind = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).cloned().collect::<Vec<_>>();
668 let turns = by_kind(SpanKind::Turn);
669 assert_eq!(turns.len(), 1);
670 assert_eq!(turns[0].duration_ms, Some(9_000), "prompt at :00, reply ended at :09");
671 let inf = by_kind(SpanKind::Inference);
672 assert_eq!(inf.len(), 2);
673 assert_eq!(inf[0].duration_ms, Some(4_000), "prompt at :00, last block of the reply at :04");
674 assert_eq!(inf[1].duration_ms, Some(4_000), "tool result at :05, reply at :09");
675 assert_eq!(by_kind(SpanKind::Tool)[0].duration_ms, Some(1_000));
676 let kinds: Vec<_> = s.spans.iter().map(|sp| sp.kind).collect();
678 assert_eq!(kinds, vec![SpanKind::Turn, SpanKind::Inference, SpanKind::Tool, SpanKind::Inference]);
679 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:00.000Z","message":{{"role":"user","content":"thanks"}}}}"#).unwrap();
681 t.refresh().unwrap();
682 let turns = by_kind_of(t.summary(), SpanKind::Turn);
683 assert_eq!(turns.len(), 2);
684 assert_eq!(turns[0].duration_ms, Some(9_000));
685 assert!(turns[1].is_open());
686 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();
691 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:06.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
692 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T07:00:00.000Z","message":{{"role":"user","content":"next day"}}}}"#)
693 .unwrap();
694 t.refresh().unwrap();
695 let turns = by_kind_of(t.summary(), SpanKind::Turn);
696 assert_eq!(turns.len(), 3);
697 assert_eq!(turns[1].duration_ms, Some(6_000), "turn 2: :01:00 to the interrupted tool result at :01:06");
698 assert!(turns[2].is_open());
699 let inf = by_kind_of(t.summary(), SpanKind::Inference);
700 assert_eq!(inf.iter().filter(|s| s.is_open()).count(), 1, "only the newest inference is open");
701 assert_eq!(inf.last().unwrap().started_at, turns[2].started_at);
702 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();
706 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:00:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
707 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();
708 t.refresh().unwrap();
709 let turns = by_kind_of(t.summary(), SpanKind::Turn);
710 assert_eq!(turns.len(), 3, "a meta line is not a prompt");
711 assert_eq!(turns[2].duration_ms, Some(2_000));
712 let inf = by_kind_of(t.summary(), SpanKind::Inference);
713 let last_two: Vec<_> = inf.iter().rev().take(2).map(|s| s.duration_ms).collect();
714 assert_eq!(last_two, vec![Some(3_000), Some(2_000)], "the command's reply is its own 3 s inference");
715 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:30:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
718 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();
719 t.refresh().unwrap();
720 let inf = by_kind_of(t.summary(), SpanKind::Inference);
721 assert!(inf.last().unwrap().is_open(), "the meta line's inference has no real reply yet");
722 assert_eq!(by_kind_of(t.summary(), SpanKind::Turn)[2].duration_ms, Some(2_000));
723 let _ = std::fs::remove_dir_all(&dir);
724 }
725
726 fn by_kind_of(s: &SessionSummary, k: SpanKind) -> Vec<crate::model::ToolSpan> {
727 s.spans.iter().filter(|sp| sp.kind == k).cloned().collect()
728 }
729
730 #[test]
731 fn folds_subagent_transcripts_into_the_parent() {
732 let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
733 let _ = std::fs::remove_dir_all(&dir);
734 std::fs::create_dir_all(&dir).unwrap();
735 let path = dir.join("s.jsonl");
736 let mut f = std::fs::File::create(&path).unwrap();
737 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();
739 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
740 t.refresh().unwrap();
741 assert_eq!(t.summary().usage.total(), 110);
742 assert_eq!(t.summary().subagent_turns, 0);
743 assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
745
746 let sub = subagents_dir(&path).unwrap();
748 std::fs::create_dir_all(&sub).unwrap();
749 let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
750 std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
751 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();
752 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();
753 t.refresh().unwrap();
754 let s = t.summary();
755 assert_eq!(s.usage.total(), 1210);
756 assert_eq!(s.turns, 2);
757 assert_eq!(s.subagent_turns, 1);
758 assert_eq!(s.tool_calls, 2);
759 assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
761 assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
762 assert_eq!(s.session_id.as_deref(), Some("abc"));
763 let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
764 assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
765 let spans: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
766 assert_eq!(spans.len(), 2);
767 assert_eq!(spans[0].name, "Agent");
768 assert!(spans[0].is_open());
769 assert_eq!(spans[1].name, "Grep");
770 assert!(spans[1].sidechain);
771 assert_eq!(spans[1].duration_ms, Some(2_000));
772 let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
775 assert_eq!(inf.len(), 1);
776 assert!(inf[0].sidechain);
777 assert!(inf[0].is_open());
778
779 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();
781 t.refresh().unwrap();
782 assert_eq!(t.summary().usage.total(), 1212);
783 assert_eq!(t.summary().subagent_turns, 2);
784 let _ = std::fs::remove_dir_all(&dir);
785 }
786
787 #[test]
788 fn builds_spans_from_tool_use_and_tool_result() {
789 let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
790 std::fs::create_dir_all(&dir).unwrap();
791 let path = dir.join("s.jsonl");
792 let mut f = std::fs::File::create(&path).unwrap();
793 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();
794 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();
796 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();
798 let mut t = ClaudeTranscript::new(&path);
799 t.refresh().unwrap();
800 let all = t.summary().spans.to_vec();
801 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
805 assert_eq!(inf.len(), 1);
806 assert_eq!(inf[0].name, "inference");
807 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no prompt line, so no turn");
808 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).cloned().collect();
809 assert_eq!(spans.len(), 3);
810 assert_eq!(spans[0].name, "Bash");
811 assert_eq!(spans[0].duration_ms, Some(2_500));
812 assert!(!spans[0].error);
813 assert_eq!(spans[1].name, "Read");
814 assert_eq!(spans[1].duration_ms, Some(2_500));
815 assert!(spans[1].error);
816 assert!(spans[2].is_open());
817 assert!(spans[2].sidechain);
818 assert_eq!(t.summary().tool_calls, 3);
819 let _ = std::fs::remove_dir_all(&dir);
820 }
821}