1use super::{
34 AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, RegistryHints, SessionSummary, SessionTracker, SpanLog, SpanRetention,
35 parse_rfc3339_utc,
36};
37use crate::jsonl::TailReader;
38use crate::model::{Activity, Attribution, CostBreakdown, Harness, ProcNode, SpanKind, TokenUsage};
39use crate::pricing::{self, Table};
40use crate::process::{RawProc, session_id_from_args};
41use serde::Deserialize;
42use serde_json::Value;
43use std::collections::{BTreeMap, HashMap, HashSet};
44use std::path::{Path, PathBuf};
45use std::time::{Duration, SystemTime, UNIX_EPOCH};
46
47pub fn home() -> Option<PathBuf> {
48 std::env::var_os("HOME").map(PathBuf::from)
49}
50
51pub fn claude_dir() -> Option<PathBuf> {
52 if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
53 return Some(PathBuf::from(d));
54 }
55 home().map(|h| h.join(".claude"))
56}
57
58pub fn sessions_dir() -> Option<PathBuf> {
59 claude_dir().map(|d| d.join("sessions"))
60}
61
62pub fn projects_dir() -> Option<PathBuf> {
63 claude_dir().map(|d| d.join("projects"))
64}
65
66pub fn encode_project_path(p: &Path) -> String {
69 p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
70}
71
72pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
73 projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
74}
75
76#[derive(Debug, Clone, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct PidSession {
80 pub pid: u32,
81 pub session_id: String,
82 pub cwd: PathBuf,
83 #[serde(default)]
84 pub name: Option<String>,
85 #[serde(default)]
86 pub status: Option<String>,
87 #[serde(default)]
88 pub version: Option<String>,
89 #[serde(default)]
90 pub kind: Option<String>,
91 #[serde(default)]
92 pub entrypoint: Option<String>,
93 #[serde(default)]
94 pub started_at: Option<u64>,
95 #[serde(default)]
96 pub updated_at: Option<u64>,
97}
98
99impl PidSession {
100 pub fn started(&self) -> Option<SystemTime> {
101 self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
102 }
103}
104
105pub fn read_pid_sessions() -> Vec<PidSession> {
108 let Some(dir) = sessions_dir() else { return Vec::new() };
109 let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
110 let mut out = Vec::new();
111 for e in rd.flatten() {
112 let p = e.path();
113 if p.extension().and_then(|x| x.to_str()) != Some("json") {
114 continue;
115 }
116 if let Ok(s) = std::fs::read_to_string(&p)
117 && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
118 {
119 out.push(ps);
120 }
121 }
122 out
123}
124
125pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
127 let Some(dir) = projects_dir() else { return Vec::new() };
128 let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
129 let mut out = Vec::new();
130 for proj in projects.flatten() {
131 let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
132 for f in files.flatten() {
133 let p = f.path();
134 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
135 continue;
136 }
137 if let Ok(md) = f.metadata()
138 && md.modified().map(|m| m >= since).unwrap_or(false)
139 {
140 out.push(p);
141 }
142 }
143 }
144 out
145}
146
147pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
150 let dir = projects_dir()?.join(encode_project_path(cwd));
151 let rd = std::fs::read_dir(&dir).ok()?;
152 let slack = Duration::from_secs(15);
153 let mut best: Option<(Duration, PathBuf)> = None;
154 for f in rd.flatten() {
155 let p = f.path();
156 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
157 continue;
158 }
159 let Ok(md) = f.metadata() else { continue };
162 let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
163 if created + slack < proc_start {
164 continue;
165 }
166 let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
167 if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
168 best = Some((gap, p));
169 }
170 }
171 best.map(|(_, p)| p)
172}
173
174pub fn is_subagent_transcript(p: &Path) -> bool {
179 p.file_name().and_then(|f| f.to_str()).map(|f| f.starts_with("agent-")).unwrap_or(false)
180}
181
182#[derive(Default)]
185pub struct ClaudeAdapter {
186 recent: Vec<PathBuf>,
187 registry: HashMap<u32, PidSession>,
188}
189
190impl HarnessAdapter for ClaudeAdapter {
191 fn harness(&self) -> Harness {
192 Harness::Claude
193 }
194
195 fn rescan(&mut self, since: SystemTime) {
196 self.recent = recent_transcripts(since);
197 }
198
199 fn prepare(&mut self, _roots: &[&ProcNode]) {
202 self.registry = read_pid_sessions().into_iter().map(|s| (s.pid, s)).collect();
203 }
204
205 fn hints(&self, pid: u32) -> Option<RegistryHints> {
206 self.registry.get(&pid).map(|r| RegistryHints {
207 name: r.name.clone(),
208 session_id: Some(r.session_id.clone()),
209 cwd: Some(r.cwd.clone()),
210 version: r.version.clone(),
211 status: r.status.clone(),
212 })
213 }
214
215 fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
216 if let Some(reg) = self.registry.get(&root.pid)
217 && let Some(p) = transcript_path(®.cwd, ®.session_id)
218 {
219 return (vec![p], Attribution::HarnessRegistry);
220 }
221 if let (Some(raw), Some(cwd)) = (raw, ctx.cwd)
222 && let Some(id) = session_id_from_args(&raw.cmd)
223 && let Some(p) = transcript_path(cwd, &id)
224 && p.exists()
225 {
226 return (vec![p], Attribution::CommandLine);
227 }
228 if let Some(cwd) = ctx.cwd
229 && let Some(p) = guess_transcript(cwd, ctx.proc_start)
230 && !ctx.attached.contains(&p)
231 {
232 return (vec![p], Attribution::CwdHeuristic);
233 }
234 (Vec::new(), Attribution::None)
235 }
236
237 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
238 self.recent.iter().filter(|p| !attached.contains(*p) && !is_subagent_transcript(p)).cloned().collect()
239 }
240
241 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
242 Box::new(ClaudeTranscript::new(path).with_spans(spans))
243 }
244
245 fn detect(&self, path: &Path) -> bool {
248 super::head_lines(path)
249 .iter()
250 .any(|v| (v.get("sessionId").is_some() || v.get("parentUuid").is_some()) && v.get("projectHash").is_none())
251 }
252
253 fn transcripts(&self) -> Vec<(String, PathBuf)> {
255 recent_transcripts(SystemTime::UNIX_EPOCH)
256 .into_iter()
257 .filter(|p| !is_subagent_transcript(p))
258 .map(|p| (p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), p))
259 .collect()
260 }
261}
262
263pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
267 let stem = transcript.file_stem()?;
268 Some(transcript.with_file_name(stem).join("subagents"))
269}
270
271struct Parser {
274 reader: TailReader,
275 summary: SessionSummary,
276 last_msg_id: Option<String>,
278 last_contrib: Contrib,
279 inference: Option<String>,
282 turn: Option<String>,
283 inferences: u64,
284 turns: u64,
285 prev_ts: Option<SystemTime>,
289 inference_ended_by: Option<String>,
294 turn_ended_by: Option<String>,
295}
296
297#[derive(Debug, Clone, Copy, Default)]
300struct Contrib {
301 usage: TokenUsage,
302 cost: CostBreakdown,
303 unpriced: u64,
304 searches: u64,
305}
306
307impl Parser {
308 fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
309 Parser {
310 reader: TailReader::new(path),
311 summary: SessionSummary { harness: Some(Harness::Claude), spans: spans.log(), ..Default::default() },
312 last_msg_id: None,
313 last_contrib: Contrib::default(),
314 inference: None,
315 turn: None,
316 inferences: 0,
317 turns: 0,
318 prev_ts: None,
319 inference_ended_by: None,
320 turn_ended_by: None,
321 }
322 }
323
324 fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
326 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
327 for l in &lines {
328 self.ingest(l, prices);
329 }
330 Ok((lines.len(), more))
331 }
332
333 fn ingest(&mut self, line: &str, prices: &Table) {
334 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
335 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
336 if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
337 if self.summary.started_at.is_none() {
338 self.summary.started_at = Some(ts);
339 }
340 self.summary.last_activity = Some(ts);
341 }
342 if self.summary.session_id.is_none() {
343 self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
344 }
345 if self.summary.cwd.is_none() {
346 self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
347 }
348 if self.summary.harness_version.is_none() {
349 self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
350 }
351 let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
352 let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
353 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
354 match kind {
355 "assistant" => self.ingest_assistant(&v, sidechain, ts, prices),
356 "user" if !is_meta => {
357 self.summary.activity = Activity::Working;
359 if let Some(ts) = ts {
360 let answered = self.close_spans(&v, ts);
361 if !answered {
362 self.begin_turn(ts, sidechain);
363 }
364 self.begin_inference(ts, sidechain);
365 }
366 }
367 "user" => {
372 if let Some(ts) = ts {
373 self.begin_inference(ts, sidechain);
374 }
375 }
376 _ => {}
377 }
378 if ts.is_some() {
379 self.prev_ts = ts;
380 }
381 }
382
383 fn begin_turn(&mut self, ts: SystemTime, sidechain: bool) {
387 if let Some(id) = self.turn.take()
388 && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
389 {
390 let ended = self.prev_ts.unwrap_or(ts).min(ts);
391 self.summary.spans.end_at(&id, ended);
392 }
393 self.turns += 1;
394 let id = format!("turn:{}", self.turns);
395 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, sidechain, SpanKind::Turn);
396 self.turn = Some(id);
397 self.turn_ended_by = None;
398 }
399
400 fn begin_inference(&mut self, ts: SystemTime, sidechain: bool) {
405 if let Some(id) = self.inference.take() {
406 self.summary.spans.discard_open(&id);
407 }
408 self.inferences += 1;
409 let id = format!("inference:{}", self.inferences);
410 self.summary.spans.open_kind(id.clone(), "inference".into(), ts, sidechain, SpanKind::Inference);
411 self.inference = Some(id);
412 self.inference_ended_by = None;
413 }
414
415 fn may_extend(ended_by: &mut Option<String>, id: Option<&str>) -> bool {
419 match (ended_by.as_deref(), id) {
420 (None, Some(id)) => {
421 *ended_by = Some(id.to_string());
422 true
423 }
424 (None, None) => true,
425 (Some(e), Some(id)) => e == id,
426 (Some(_), None) => false,
427 }
428 }
429
430 fn close_spans(&mut self, v: &Value, ts: SystemTime) -> bool {
434 let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return false };
435 let mut answered = false;
436 for b in content {
437 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
438 continue;
439 }
440 answered = true;
441 let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
442 self.summary.spans.close(id, ts, b.get("is_error").and_then(Value::as_bool).unwrap_or(false));
443 }
444 answered
445 }
446
447 fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
448 let Some(msg) = v.get("message") else { return };
449 let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
450 let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
451 if !model.is_empty() && model != "<synthetic>" {
452 self.summary.model = Some(model.to_string());
453 }
454 if let Some(content) = msg.get("content").and_then(Value::as_array) {
455 let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
456 for b in calls {
457 self.summary.tool_calls += 1;
458 if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
459 let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
460 self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
461 }
462 }
463 }
464 let synthetic = model == "<synthetic>";
469 if let (Some(ts), Some(span), false) = (ts, self.inference.as_deref(), synthetic) {
470 if Self::may_extend(&mut self.inference_ended_by, id.as_deref()) {
471 self.summary.spans.end_at(span, ts);
472 } else {
473 self.inference = None;
474 }
475 }
476 match msg.get("stop_reason").and_then(Value::as_str) {
477 Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
478 self.summary.activity = Activity::Waiting;
479 if let (Some(ts), Some(span), false) = (ts, self.turn.as_deref(), synthetic) {
483 if Self::may_extend(&mut self.turn_ended_by, id.as_deref()) {
484 self.summary.spans.end_at(span, ts);
485 } else {
486 self.turn = None;
487 }
488 }
489 }
490 _ => self.summary.activity = Activity::Working,
491 }
492
493 if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
496 self.summary.health.billable_messages += 1;
497 }
498 let usage = match msg.get("usage") {
499 Some(u) => {
500 let parsed = parse_usage(u);
501 self.summary.health.usage_records += 1;
502 if parsed.total() == 0 {
503 self.summary.health.empty_usage_records += 1;
504 }
505 parsed
506 }
507 None => TokenUsage::default(),
508 };
509 let price = prices.lookup(model);
510 let searches = msg.pointer("/usage/server_tool_use/web_search_requests").and_then(Value::as_u64).unwrap_or(0);
513 let mut cost = price.map(|p| p.breakdown(&usage)).unwrap_or_default();
514 cost.web_search = prices.web_search_cost(searches);
515 let unpriced = if price.is_none() { usage.total() } else { 0 };
516
517 let same_message = id.is_some() && id == self.last_msg_id;
518 if same_message {
519 let c = self.last_contrib;
521 self.summary.usage.sub(&c.usage);
522 self.summary.cost_usd -= c.cost.total();
523 self.summary.cost_breakdown.sub(&c.cost);
524 self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(c.unpriced);
525 self.summary.web_searches = self.summary.web_searches.saturating_sub(c.searches);
526 } else {
527 self.summary.turns += 1;
528 if sidechain {
529 self.summary.subagent_turns += 1;
530 }
531 }
532 self.summary.usage.add(&usage);
533 self.summary.cost_usd += cost.total();
534 self.summary.cost_breakdown.add(&cost);
535 self.summary.unpriced_tokens += unpriced;
536 self.summary.web_searches += searches;
537 self.last_msg_id = id;
538 self.last_contrib = Contrib { usage, cost, unpriced, searches };
539 }
540}
541
542pub struct ClaudeTranscript {
553 main: Parser,
554 subagents: BTreeMap<PathBuf, Parser>,
556 prices: &'static Table,
557 retention: SpanRetention,
558 summary: SessionSummary,
561}
562
563impl ClaudeTranscript {
564 pub fn new(path: impl Into<PathBuf>) -> Self {
565 let retention = SpanRetention::Recent;
566 ClaudeTranscript {
567 main: Parser::new(path, retention),
568 subagents: BTreeMap::new(),
569 prices: pricing::table(),
570 retention,
571 summary: SessionSummary { harness: Some(Harness::Claude), ..Default::default() },
572 }
573 }
574
575 pub fn with_prices(mut self, prices: &'static Table) -> Self {
578 self.prices = prices;
579 self
580 }
581
582 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
584 self.retention = retention;
585 self.main.summary.spans = retention.log();
586 for p in self.subagents.values_mut() {
587 p.summary.spans = retention.log();
588 }
589 self
590 }
591
592 pub fn set_registry_hints(&mut self, ps: &PidSession) {
593 let s = &mut self.main.summary;
594 s.session_id.get_or_insert_with(|| ps.session_id.clone());
595 s.cwd.get_or_insert_with(|| ps.cwd.clone());
596 if ps.version.is_some() {
597 s.harness_version = ps.version.clone();
598 }
599 if s.started_at.is_none() {
600 s.started_at = ps.started();
601 }
602 self.fold();
603 }
604
605 fn discover_subagents(&mut self) {
609 let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
610 let Ok(rd) = std::fs::read_dir(&dir) else { return };
611 for e in rd.flatten() {
612 let p = e.path();
613 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
614 continue;
615 }
616 let parser = Parser::new(&p, self.retention);
617 self.subagents.insert(p, parser);
618 }
619 }
620
621 fn fold(&mut self) {
622 let mut s = self.main.summary.clone();
623 for c in self.subagents.values() {
624 let t = &c.summary;
625 s.usage.add(&t.usage);
626 s.cost_usd += t.cost_usd;
627 s.cost_breakdown.add(&t.cost_breakdown);
628 s.unpriced_tokens += t.unpriced_tokens;
629 s.turns += t.turns;
630 s.subagent_turns += t.subagent_turns;
631 s.tool_calls += t.tool_calls;
632 s.web_searches += t.web_searches;
633 s.health.billable_messages += t.health.billable_messages;
634 s.health.usage_records += t.health.usage_records;
635 s.health.empty_usage_records += t.health.empty_usage_records;
636 s.last_activity = s.last_activity.max(t.last_activity);
637 }
638 if !self.subagents.is_empty() {
639 let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
640 s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
641 }
642 self.summary = s;
643 }
644}
645
646fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
647 matches!((a, b), (Some(x), Some(y)) if x == y)
648}
649
650fn parse_usage(u: &Value) -> TokenUsage {
651 let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
652 let cache_write_total = g("cache_creation_input_tokens");
653 let (w1h, w5m) = match u.get("cache_creation") {
654 Some(cc) => (
655 cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
656 cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
657 ),
658 None => (0, 0),
659 };
660 let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
662 TokenUsage {
663 input: g("input_tokens"),
664 cache_write_5m: w5m,
665 cache_write_1h: w1h,
666 cache_read: g("cache_read_input_tokens"),
667 output: g("output_tokens"),
668 }
669}
670
671impl SessionTracker for ClaudeTranscript {
672 fn refresh(&mut self) -> anyhow::Result<bool> {
673 let (mut ingested, mut more) = self.main.refresh(self.prices)?;
674 self.discover_subagents();
675 for c in self.subagents.values_mut() {
676 if let Ok((n, m)) = c.refresh(self.prices) {
678 ingested += n;
679 more |= m;
680 }
681 }
682 if ingested > 0 || self.summary.session_id.is_none() {
683 self.fold();
684 }
685 Ok(more)
686 }
687
688 fn summary(&self) -> &SessionSummary {
689 &self.summary
690 }
691
692 fn path(&self) -> &Path {
693 self.main.reader.path()
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use std::io::Write;
701
702 #[test]
703 fn encodes_paths_like_claude_code() {
704 assert_eq!(
705 encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
706 "-Users-atlas-Documents-orbital-forge-agent-top"
707 );
708 assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
709 }
710
711 #[test]
712 fn dedupes_usage_by_message_id_and_tracks_state() {
713 let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
714 std::fs::create_dir_all(&dir).unwrap();
715 let path = dir.join("s.jsonl");
716 let mut f = std::fs::File::create(&path).unwrap();
717 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}}"#;
718 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
719 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();
720 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();
721 let mut t = ClaudeTranscript::new(&path);
722 t.refresh().unwrap();
723 let s = t.summary();
724 assert_eq!(s.turns, 1);
725 assert_eq!(s.tool_calls, 1);
726 assert_eq!(s.usage.total(), 1152);
727 assert_eq!(s.activity, Activity::Working);
728 assert_eq!(s.session_id.as_deref(), Some("abc"));
729 assert!((s.cost_usd - 0.001104).abs() < 1e-9);
731 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();
732 t.refresh().unwrap();
733 assert_eq!(t.summary().turns, 2);
734 assert_eq!(t.summary().activity, Activity::Waiting);
735 let _ = std::fs::remove_dir_all(&dir);
736 }
737
738 #[test]
739 fn reconstructs_turns_inferences_and_web_searches() {
740 let dir = std::env::temp_dir().join(format!("agent-top-claude-turns-{}", std::process::id()));
741 std::fs::create_dir_all(&dir).unwrap();
742 let path = dir.join("s.jsonl");
743 let mut f = std::fs::File::create(&path).unwrap();
744 let usage = r#"{"input_tokens":100,"output_tokens":10,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5}}"#;
745 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"look it up"}}}}"#)
747 .unwrap();
748 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();
749 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();
750 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:05.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}}]}}}}"#).unwrap();
752 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();
753 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
754 t.refresh().unwrap();
755 let s = t.summary();
756 assert_eq!(s.web_searches, 2);
758 assert!((s.cost_usd - (0.000300 + 0.02 + 0.000012)).abs() < 1e-9, "{}", s.cost_usd);
760 let by_kind = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).cloned().collect::<Vec<_>>();
761 let turns = by_kind(SpanKind::Turn);
762 assert_eq!(turns.len(), 1);
763 assert_eq!(turns[0].duration_ms, Some(9_000), "prompt at :00, reply ended at :09");
764 let inf = by_kind(SpanKind::Inference);
765 assert_eq!(inf.len(), 2);
766 assert_eq!(inf[0].duration_ms, Some(4_000), "prompt at :00, last block of the reply at :04");
767 assert_eq!(inf[1].duration_ms, Some(4_000), "tool result at :05, reply at :09");
768 assert_eq!(by_kind(SpanKind::Tool)[0].duration_ms, Some(1_000));
769 let kinds: Vec<_> = s.spans.iter().map(|sp| sp.kind).collect();
771 assert_eq!(kinds, vec![SpanKind::Turn, SpanKind::Inference, SpanKind::Tool, SpanKind::Inference]);
772 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:00.000Z","message":{{"role":"user","content":"thanks"}}}}"#).unwrap();
774 t.refresh().unwrap();
775 let turns = by_kind_of(t.summary(), SpanKind::Turn);
776 assert_eq!(turns.len(), 2);
777 assert_eq!(turns[0].duration_ms, Some(9_000));
778 assert!(turns[1].is_open());
779 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();
784 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:06.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
785 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T07:00:00.000Z","message":{{"role":"user","content":"next day"}}}}"#)
786 .unwrap();
787 t.refresh().unwrap();
788 let turns = by_kind_of(t.summary(), SpanKind::Turn);
789 assert_eq!(turns.len(), 3);
790 assert_eq!(turns[1].duration_ms, Some(6_000), "turn 2: :01:00 to the interrupted tool result at :01:06");
791 assert!(turns[2].is_open());
792 let inf = by_kind_of(t.summary(), SpanKind::Inference);
793 assert_eq!(inf.iter().filter(|s| s.is_open()).count(), 1, "only the newest inference is open");
794 assert_eq!(inf.last().unwrap().started_at, turns[2].started_at);
795 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();
799 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:00:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
800 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();
801 t.refresh().unwrap();
802 let turns = by_kind_of(t.summary(), SpanKind::Turn);
803 assert_eq!(turns.len(), 3, "a meta line is not a prompt");
804 assert_eq!(turns[2].duration_ms, Some(2_000));
805 let inf = by_kind_of(t.summary(), SpanKind::Inference);
806 let last_two: Vec<_> = inf.iter().rev().take(2).map(|s| s.duration_ms).collect();
807 assert_eq!(last_two, vec![Some(3_000), Some(2_000)], "the command's reply is its own 3 s inference");
808 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:30:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
811 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();
812 t.refresh().unwrap();
813 let inf = by_kind_of(t.summary(), SpanKind::Inference);
814 assert!(inf.last().unwrap().is_open(), "the meta line's inference has no real reply yet");
815 assert_eq!(by_kind_of(t.summary(), SpanKind::Turn)[2].duration_ms, Some(2_000));
816 let _ = std::fs::remove_dir_all(&dir);
817 }
818
819 fn by_kind_of(s: &SessionSummary, k: SpanKind) -> Vec<crate::model::ToolSpan> {
820 s.spans.iter().filter(|sp| sp.kind == k).cloned().collect()
821 }
822
823 #[test]
824 fn folds_subagent_transcripts_into_the_parent() {
825 let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
826 let _ = std::fs::remove_dir_all(&dir);
827 std::fs::create_dir_all(&dir).unwrap();
828 let path = dir.join("s.jsonl");
829 let mut f = std::fs::File::create(&path).unwrap();
830 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();
832 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
833 t.refresh().unwrap();
834 assert_eq!(t.summary().usage.total(), 110);
835 assert_eq!(t.summary().subagent_turns, 0);
836 assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
838
839 let sub = subagents_dir(&path).unwrap();
841 std::fs::create_dir_all(&sub).unwrap();
842 let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
843 std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
844 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();
845 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();
846 t.refresh().unwrap();
847 let s = t.summary();
848 assert_eq!(s.usage.total(), 1210);
849 assert_eq!(s.turns, 2);
850 assert_eq!(s.subagent_turns, 1);
851 assert_eq!(s.tool_calls, 2);
852 assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
854 assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
855 assert_eq!(s.session_id.as_deref(), Some("abc"));
856 let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
857 assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
858 let spans: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
859 assert_eq!(spans.len(), 2);
860 assert_eq!(spans[0].name, "Agent");
861 assert!(spans[0].is_open());
862 assert_eq!(spans[1].name, "Grep");
863 assert!(spans[1].sidechain);
864 assert_eq!(spans[1].duration_ms, Some(2_000));
865 let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
868 assert_eq!(inf.len(), 1);
869 assert!(inf[0].sidechain);
870 assert!(inf[0].is_open());
871
872 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();
874 t.refresh().unwrap();
875 assert_eq!(t.summary().usage.total(), 1212);
876 assert_eq!(t.summary().subagent_turns, 2);
877 let _ = std::fs::remove_dir_all(&dir);
878 }
879
880 #[test]
881 fn builds_spans_from_tool_use_and_tool_result() {
882 let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
883 std::fs::create_dir_all(&dir).unwrap();
884 let path = dir.join("s.jsonl");
885 let mut f = std::fs::File::create(&path).unwrap();
886 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();
887 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();
889 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();
891 let mut t = ClaudeTranscript::new(&path);
892 t.refresh().unwrap();
893 let all = t.summary().spans.to_vec();
894 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
898 assert_eq!(inf.len(), 1);
899 assert_eq!(inf[0].name, "inference");
900 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no prompt line, so no turn");
901 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).cloned().collect();
902 assert_eq!(spans.len(), 3);
903 assert_eq!(spans[0].name, "Bash");
904 assert_eq!(spans[0].duration_ms, Some(2_500));
905 assert!(!spans[0].error);
906 assert_eq!(spans[1].name, "Read");
907 assert_eq!(spans[1].duration_ms, Some(2_500));
908 assert!(spans[1].error);
909 assert!(spans[2].is_open());
910 assert!(spans[2].sidechain);
911 assert_eq!(t.summary().tool_calls, 3);
912 let _ = std::fs::remove_dir_all(&dir);
913 }
914}