1use super::{
41 AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, RegistryHints, SessionSummary, SessionTracker, SpanLog, SpanRetention,
42 mcp_server_of, parse_rfc3339_utc,
43};
44use crate::jsonl::TailReader;
45use crate::model::{Activity, Attribution, ContextOrigin, CostBreakdown, Harness, ProcNode, SpanKind, TokenUsage};
46use crate::pricing::{self, Table};
47use crate::process::{RawProc, session_id_from_args};
48use serde::Deserialize;
49use serde_json::Value;
50use std::collections::{BTreeMap, HashMap, HashSet};
51use std::path::{Path, PathBuf};
52use std::time::{Duration, SystemTime, UNIX_EPOCH};
53
54pub fn home() -> Option<PathBuf> {
55 std::env::var_os("HOME").map(PathBuf::from)
56}
57
58pub fn claude_dir() -> Option<PathBuf> {
59 if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
60 return Some(PathBuf::from(d));
61 }
62 home().map(|h| h.join(".claude"))
63}
64
65pub fn sessions_dir() -> Option<PathBuf> {
66 claude_dir().map(|d| d.join("sessions"))
67}
68
69pub fn projects_dir() -> Option<PathBuf> {
70 claude_dir().map(|d| d.join("projects"))
71}
72
73pub fn encode_project_path(p: &Path) -> String {
76 p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
77}
78
79pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
80 projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
81}
82
83#[derive(Debug, Clone, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct PidSession {
87 pub pid: u32,
88 pub session_id: String,
89 pub cwd: PathBuf,
90 #[serde(default)]
91 pub name: Option<String>,
92 #[serde(default)]
93 pub status: Option<String>,
94 #[serde(default)]
95 pub version: Option<String>,
96 #[serde(default)]
97 pub kind: Option<String>,
98 #[serde(default)]
99 pub entrypoint: Option<String>,
100 #[serde(default)]
101 pub started_at: Option<u64>,
102 #[serde(default)]
103 pub updated_at: Option<u64>,
104}
105
106impl PidSession {
107 pub fn started(&self) -> Option<SystemTime> {
108 self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
109 }
110}
111
112pub fn read_pid_sessions() -> Vec<PidSession> {
115 let Some(dir) = sessions_dir() else { return Vec::new() };
116 let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
117 let mut out = Vec::new();
118 for e in rd.flatten() {
119 let p = e.path();
120 if p.extension().and_then(|x| x.to_str()) != Some("json") {
121 continue;
122 }
123 if let Ok(s) = std::fs::read_to_string(&p)
124 && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
125 {
126 out.push(ps);
127 }
128 }
129 out
130}
131
132pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
134 let Some(dir) = projects_dir() else { return Vec::new() };
135 let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
136 let mut out = Vec::new();
137 for proj in projects.flatten() {
138 let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
139 for f in files.flatten() {
140 let p = f.path();
141 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
142 continue;
143 }
144 if let Ok(md) = f.metadata()
145 && md.modified().map(|m| m >= since).unwrap_or(false)
146 {
147 out.push(p);
148 }
149 }
150 }
151 out
152}
153
154pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
157 let dir = projects_dir()?.join(encode_project_path(cwd));
158 let rd = std::fs::read_dir(&dir).ok()?;
159 let slack = Duration::from_secs(15);
160 let mut best: Option<(Duration, PathBuf)> = None;
161 for f in rd.flatten() {
162 let p = f.path();
163 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
164 continue;
165 }
166 let Ok(md) = f.metadata() else { continue };
169 let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
170 if created + slack < proc_start {
171 continue;
172 }
173 let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
174 if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
175 best = Some((gap, p));
176 }
177 }
178 best.map(|(_, p)| p)
179}
180
181pub fn is_subagent_transcript(p: &Path) -> bool {
186 p.file_name().and_then(|f| f.to_str()).map(|f| f.starts_with("agent-")).unwrap_or(false)
187}
188
189#[derive(Default)]
192pub struct ClaudeAdapter {
193 recent: Vec<PathBuf>,
194 registry: HashMap<u32, PidSession>,
195}
196
197impl HarnessAdapter for ClaudeAdapter {
198 fn harness(&self) -> Harness {
199 Harness::Claude
200 }
201
202 fn rescan(&mut self, since: SystemTime) {
203 self.recent = recent_transcripts(since);
204 }
205
206 fn prepare(&mut self, _roots: &[&ProcNode]) {
209 self.registry = read_pid_sessions().into_iter().map(|s| (s.pid, s)).collect();
210 }
211
212 fn hints(&self, pid: u32) -> Option<RegistryHints> {
213 self.registry.get(&pid).map(|r| RegistryHints {
214 name: r.name.clone(),
215 session_id: Some(r.session_id.clone()),
216 cwd: Some(r.cwd.clone()),
217 version: r.version.clone(),
218 status: r.status.clone(),
219 })
220 }
221
222 fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
223 if let Some(reg) = self.registry.get(&root.pid)
224 && let Some(p) = transcript_path(®.cwd, ®.session_id)
225 {
226 return (vec![p], Attribution::HarnessRegistry);
227 }
228 if let (Some(raw), Some(cwd)) = (raw, ctx.cwd)
229 && let Some(id) = session_id_from_args(&raw.cmd)
230 && let Some(p) = transcript_path(cwd, &id)
231 && p.exists()
232 {
233 return (vec![p], Attribution::CommandLine);
234 }
235 if let Some(cwd) = ctx.cwd
236 && let Some(p) = guess_transcript(cwd, ctx.proc_start)
237 && !ctx.attached.contains(&p)
238 {
239 return (vec![p], Attribution::CwdHeuristic);
240 }
241 (Vec::new(), Attribution::None)
242 }
243
244 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
245 self.recent.iter().filter(|p| !attached.contains(*p) && !is_subagent_transcript(p)).cloned().collect()
246 }
247
248 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
249 Box::new(ClaudeTranscript::new(path).with_spans(spans))
250 }
251
252 fn detect(&self, path: &Path) -> bool {
255 super::head_lines(path)
256 .iter()
257 .any(|v| (v.get("sessionId").is_some() || v.get("parentUuid").is_some()) && v.get("projectHash").is_none())
258 }
259
260 fn transcripts(&self) -> Vec<(String, PathBuf)> {
262 recent_transcripts(SystemTime::UNIX_EPOCH)
263 .into_iter()
264 .filter(|p| !is_subagent_transcript(p))
265 .map(|p| (p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), p))
266 .collect()
267 }
268}
269
270pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
274 let stem = transcript.file_stem()?;
275 Some(transcript.with_file_name(stem).join("subagents"))
276}
277
278struct Parser {
281 reader: TailReader,
282 summary: SessionSummary,
283 last_msg_id: Option<String>,
285 last_contrib: Contrib,
286 inference: Option<String>,
289 turn: Option<String>,
290 inferences: u64,
291 turns: u64,
292 prev_ts: Option<SystemTime>,
296 inference_ended_by: Option<String>,
301 turn_ended_by: Option<String>,
302 pending_tools: HashMap<String, String>,
306}
307
308#[derive(Debug, Clone, Copy, Default)]
311struct Contrib {
312 usage: TokenUsage,
313 cost: CostBreakdown,
314 unpriced: u64,
315 searches: u64,
316}
317
318impl Parser {
319 fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
320 Parser {
321 reader: TailReader::new(path),
322 summary: SessionSummary { harness: Some(Harness::Claude), spans: spans.log(), ..Default::default() },
323 last_msg_id: None,
324 last_contrib: Contrib::default(),
325 inference: None,
326 turn: None,
327 inferences: 0,
328 turns: 0,
329 prev_ts: None,
330 inference_ended_by: None,
331 turn_ended_by: None,
332 pending_tools: HashMap::new(),
333 }
334 }
335
336 fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
338 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
339 for l in &lines {
340 self.ingest(l, prices);
341 }
342 Ok((lines.len(), more))
343 }
344
345 fn ingest(&mut self, line: &str, prices: &Table) {
346 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
347 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
348 if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
349 if self.summary.started_at.is_none() {
350 self.summary.started_at = Some(ts);
351 }
352 self.summary.last_activity = Some(ts);
353 }
354 if self.summary.session_id.is_none() {
355 self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
356 }
357 if self.summary.cwd.is_none() {
358 self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
359 }
360 if self.summary.harness_version.is_none() {
361 self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
362 }
363 let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
364 let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
365 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
366 match kind {
367 "assistant" => self.ingest_assistant(&v, sidechain, ts, prices),
368 "user" if !is_meta => {
369 self.summary.activity = Activity::Working;
371 if let Some(ts) = ts {
372 let answered = self.close_spans(&v, ts);
373 if !answered {
374 self.begin_turn(ts, sidechain);
375 }
376 self.begin_inference(ts, sidechain);
377 }
378 }
379 "user" => {
384 if let Some(ts) = ts {
385 self.begin_inference(ts, sidechain);
386 }
387 }
388 "system" if v.get("subtype").and_then(Value::as_str) == Some("compact_boundary") => {
389 self.summary.context.compacted();
390 }
391 _ => {}
392 }
393 if ts.is_some() {
394 self.prev_ts = ts;
395 }
396 }
397
398 fn begin_turn(&mut self, ts: SystemTime, sidechain: bool) {
402 if let Some(id) = self.turn.take()
403 && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
404 {
405 let ended = self.prev_ts.unwrap_or(ts).min(ts);
406 self.summary.spans.end_at(&id, ended);
407 }
408 self.turns += 1;
409 let id = format!("turn:{}", self.turns);
410 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, sidechain, SpanKind::Turn);
411 self.turn = Some(id);
412 self.turn_ended_by = None;
413 }
414
415 fn begin_inference(&mut self, ts: SystemTime, sidechain: bool) {
420 if let Some(id) = self.inference.take() {
421 self.summary.spans.discard_open(&id);
422 }
423 self.inferences += 1;
424 let id = format!("inference:{}", self.inferences);
425 self.summary.spans.open_kind(id.clone(), "inference".into(), ts, sidechain, SpanKind::Inference);
426 self.inference = Some(id);
427 self.inference_ended_by = None;
428 }
429
430 fn may_extend(ended_by: &mut Option<String>, id: Option<&str>) -> bool {
434 match (ended_by.as_deref(), id) {
435 (None, Some(id)) => {
436 *ended_by = Some(id.to_string());
437 true
438 }
439 (None, None) => true,
440 (Some(e), Some(id)) => e == id,
441 (Some(_), None) => false,
442 }
443 }
444
445 fn close_spans(&mut self, v: &Value, ts: SystemTime) -> bool {
449 let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return false };
450 let mut answered = false;
451 for b in content {
452 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
453 continue;
454 }
455 answered = true;
456 let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
457 let error = b.get("is_error").and_then(Value::as_bool).unwrap_or(false);
458 self.summary.spans.close(id, ts, error);
459 let name = self.pending_tools.remove(id).unwrap_or_else(|| "tool".into());
460 match mcp_server_of(&name) {
461 Some(server) => {
462 if error {
463 self.summary.mcp.entry(server.to_string()).or_default().errors += 1;
464 }
465 self.summary.context.result(id, ContextOrigin::Mcp, server);
466 }
467 None => self.summary.context.result(id, ContextOrigin::Tool, &name),
468 }
469 }
470 answered
471 }
472
473 fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
474 let Some(msg) = v.get("message") else { return };
475 let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
476 let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
477 if !model.is_empty() && model != "<synthetic>" {
478 self.summary.model = Some(model.to_string());
479 }
480 if let Some(content) = msg.get("content").and_then(Value::as_array) {
481 let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
482 for b in calls {
483 self.summary.tool_calls += 1;
484 let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
485 if let Some(server) = mcp_server_of(name) {
486 let u = self.summary.mcp.entry(server.to_string()).or_default();
487 u.calls += 1;
488 u.last_call = u.last_call.max(ts);
489 }
490 if let Some(id) = b.get("id").and_then(Value::as_str) {
491 self.pending_tools.insert(id.to_string(), name.to_string());
492 }
493 if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
494 self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
495 }
496 }
497 }
498 let synthetic = model == "<synthetic>";
503 if let (Some(ts), Some(span), false) = (ts, self.inference.as_deref(), synthetic) {
504 if Self::may_extend(&mut self.inference_ended_by, id.as_deref()) {
505 self.summary.spans.end_at(span, ts);
506 } else {
507 self.inference = None;
508 }
509 }
510 match msg.get("stop_reason").and_then(Value::as_str) {
511 Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
512 self.summary.activity = Activity::Waiting;
513 if let (Some(ts), Some(span), false) = (ts, self.turn.as_deref(), synthetic) {
517 if Self::may_extend(&mut self.turn_ended_by, id.as_deref()) {
518 self.summary.spans.end_at(span, ts);
519 } else {
520 self.turn = None;
521 }
522 }
523 }
524 _ => self.summary.activity = Activity::Working,
525 }
526
527 if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
530 self.summary.health.billable_messages += 1;
531 }
532 let usage = match msg.get("usage") {
533 Some(u) => {
534 let parsed = parse_usage(u);
535 self.summary.health.usage_records += 1;
536 if parsed.total() == 0 {
537 self.summary.health.empty_usage_records += 1;
538 }
539 parsed
540 }
541 None => TokenUsage::default(),
542 };
543 let price = prices.lookup(model);
544 let searches = msg.pointer("/usage/server_tool_use/web_search_requests").and_then(Value::as_u64).unwrap_or(0);
547 let mut cost = price.map(|p| p.breakdown(&usage)).unwrap_or_default();
548 cost.web_search = prices.web_search_cost(searches);
549 let unpriced = if price.is_none() { usage.total() } else { 0 };
550
551 let same_message = id.is_some() && id == self.last_msg_id;
552 if same_message {
553 let c = self.last_contrib;
555 self.summary.usage.sub(&c.usage);
556 self.summary.cost_usd -= c.cost.total();
557 self.summary.cost_breakdown.sub(&c.cost);
558 self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(c.unpriced);
559 self.summary.web_searches = self.summary.web_searches.saturating_sub(c.searches);
560 } else {
561 self.summary.turns += 1;
562 if sidechain {
563 self.summary.subagent_turns += 1;
564 }
565 self.summary.context.response(&usage, &cost);
568 }
569 self.summary.usage.add(&usage);
570 self.summary.cost_usd += cost.total();
571 self.summary.cost_breakdown.add(&cost);
572 self.summary.unpriced_tokens += unpriced;
573 self.summary.web_searches += searches;
574 self.last_msg_id = id;
575 self.last_contrib = Contrib { usage, cost, unpriced, searches };
576 }
577}
578
579pub struct ClaudeTranscript {
590 main: Parser,
591 subagents: BTreeMap<PathBuf, Parser>,
593 prices: &'static Table,
594 retention: SpanRetention,
595 summary: SessionSummary,
598}
599
600impl ClaudeTranscript {
601 pub fn new(path: impl Into<PathBuf>) -> Self {
602 let retention = SpanRetention::Recent;
603 ClaudeTranscript {
604 main: Parser::new(path, retention),
605 subagents: BTreeMap::new(),
606 prices: pricing::table(),
607 retention,
608 summary: SessionSummary { harness: Some(Harness::Claude), ..Default::default() },
609 }
610 }
611
612 pub fn with_prices(mut self, prices: &'static Table) -> Self {
615 self.prices = prices;
616 self
617 }
618
619 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
621 self.retention = retention;
622 self.main.summary.spans = retention.log();
623 for p in self.subagents.values_mut() {
624 p.summary.spans = retention.log();
625 }
626 self
627 }
628
629 pub fn set_registry_hints(&mut self, ps: &PidSession) {
630 let s = &mut self.main.summary;
631 s.session_id.get_or_insert_with(|| ps.session_id.clone());
632 s.cwd.get_or_insert_with(|| ps.cwd.clone());
633 if ps.version.is_some() {
634 s.harness_version = ps.version.clone();
635 }
636 if s.started_at.is_none() {
637 s.started_at = ps.started();
638 }
639 self.fold();
640 }
641
642 fn discover_subagents(&mut self) {
646 let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
647 let Ok(rd) = std::fs::read_dir(&dir) else { return };
648 for e in rd.flatten() {
649 let p = e.path();
650 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
651 continue;
652 }
653 let parser = Parser::new(&p, self.retention);
654 self.subagents.insert(p, parser);
655 }
656 }
657
658 fn fold(&mut self) {
659 let mut s = self.main.summary.clone();
660 for c in self.subagents.values() {
661 let t = &c.summary;
662 s.usage.add(&t.usage);
663 s.cost_usd += t.cost_usd;
664 s.cost_breakdown.add(&t.cost_breakdown);
665 s.unpriced_tokens += t.unpriced_tokens;
666 s.turns += t.turns;
667 s.subagent_turns += t.subagent_turns;
668 s.tool_calls += t.tool_calls;
669 s.web_searches += t.web_searches;
670 s.health.billable_messages += t.health.billable_messages;
671 s.health.usage_records += t.health.usage_records;
672 s.health.empty_usage_records += t.health.empty_usage_records;
673 s.last_activity = s.last_activity.max(t.last_activity);
674 for (server, u) in &t.mcp {
675 s.mcp.entry(server.clone()).or_default().add(u);
676 }
677 s.context.merge(&t.context);
678 }
679 if !self.subagents.is_empty() {
680 let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
681 s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
682 }
683 self.summary = s;
684 }
685}
686
687fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
688 matches!((a, b), (Some(x), Some(y)) if x == y)
689}
690
691fn parse_usage(u: &Value) -> TokenUsage {
692 let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
693 let cache_write_total = g("cache_creation_input_tokens");
694 let (w1h, w5m) = match u.get("cache_creation") {
695 Some(cc) => (
696 cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
697 cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
698 ),
699 None => (0, 0),
700 };
701 let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
703 TokenUsage {
704 input: g("input_tokens"),
705 cache_write_5m: w5m,
706 cache_write_1h: w1h,
707 cache_read: g("cache_read_input_tokens"),
708 output: g("output_tokens"),
709 }
710}
711
712impl SessionTracker for ClaudeTranscript {
713 fn refresh(&mut self) -> anyhow::Result<bool> {
714 let (mut ingested, mut more) = self.main.refresh(self.prices)?;
715 self.discover_subagents();
716 for c in self.subagents.values_mut() {
717 if let Ok((n, m)) = c.refresh(self.prices) {
719 ingested += n;
720 more |= m;
721 }
722 }
723 if ingested > 0 || self.summary.session_id.is_none() {
724 self.fold();
725 }
726 Ok(more)
727 }
728
729 fn summary(&self) -> &SessionSummary {
730 &self.summary
731 }
732
733 fn path(&self) -> &Path {
734 self.main.reader.path()
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use std::io::Write;
742
743 #[test]
744 fn encodes_paths_like_claude_code() {
745 assert_eq!(
746 encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
747 "-Users-atlas-Documents-orbital-forge-agent-top"
748 );
749 assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
750 }
751
752 #[test]
753 fn dedupes_usage_by_message_id_and_tracks_state() {
754 let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
755 std::fs::create_dir_all(&dir).unwrap();
756 let path = dir.join("s.jsonl");
757 let mut f = std::fs::File::create(&path).unwrap();
758 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}}"#;
759 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
760 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();
761 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();
762 let mut t = ClaudeTranscript::new(&path);
763 t.refresh().unwrap();
764 let s = t.summary();
765 assert_eq!(s.turns, 1);
766 assert_eq!(s.tool_calls, 1);
767 assert_eq!(s.usage.total(), 1152);
768 assert_eq!(s.activity, Activity::Working);
769 assert_eq!(s.session_id.as_deref(), Some("abc"));
770 assert!((s.cost_usd - 0.001104).abs() < 1e-9);
772 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();
773 t.refresh().unwrap();
774 assert_eq!(t.summary().turns, 2);
775 assert_eq!(t.summary().activity, Activity::Waiting);
776 let _ = std::fs::remove_dir_all(&dir);
777 }
778
779 #[test]
780 fn counts_calls_per_mcp_server_and_their_errors() {
781 let dir = std::env::temp_dir().join(format!("agent-top-claude-mcp-{}", std::process::id()));
782 std::fs::create_dir_all(&dir).unwrap();
783 let path = dir.join("s.jsonl");
784 let mut f = std::fs::File::create(&path).unwrap();
785 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();
786 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();
787 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();
788 let mut t = ClaudeTranscript::new(&path);
789 t.refresh().unwrap();
790 let s = t.summary();
791 assert_eq!(s.tool_calls, 4, "MCP calls are tool calls too");
792 assert_eq!(s.mcp.len(), 2, "Bash is not a server");
793 let fs = &s.mcp["filesystem"];
794 assert_eq!((fs.calls, fs.errors), (2, 0));
795 assert_eq!(fs.last_call, parse_rfc3339_utc("2026-09-03T07:00:05.000Z"));
796 let cd = &s.mcp["chrome-devtools"];
797 assert_eq!((cd.calls, cd.errors), (1, 1), "the failed result is charged to its server, not to Bash's");
798 let _ = std::fs::remove_dir_all(&dir);
799 }
800
801 #[test]
802 fn files_context_growth_under_each_tool_and_resets_at_a_compaction() {
803 let dir = std::env::temp_dir().join(format!("agent-top-claude-context-{}", std::process::id()));
804 std::fs::create_dir_all(&dir).unwrap();
805 let path = dir.join("s.jsonl");
806 let mut f = std::fs::File::create(&path).unwrap();
807 let usage = |read: u64, out: u64| format!(r#"{{"cache_read_input_tokens":{read},"output_tokens":{out}}}"#);
808 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"go"}}}}"#).unwrap();
810 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();
811 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();
813 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();
814 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();
816 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
817 t.refresh().unwrap();
818 let by_name = |s: &SessionSummary| -> std::collections::HashMap<String, crate::model::ContextSource> {
819 s.context.sources().into_iter().map(|c| (c.name.clone(), c)).collect()
820 };
821 let c = by_name(t.summary());
822 assert_eq!(c["Read"].tokens, 2_000);
823 assert_eq!((c["fs"].tokens, c["fs"].origin, c["fs"].calls), (2_000, ContextOrigin::Mcp, 1));
824 assert_eq!(c["other"].tokens, 10_100);
825 assert!((c["Read"].cost_usd - 2_000.0 * 0.2 / 1e6).abs() < 1e-12, "{}", c["Read"].cost_usd);
828 assert!((c["other"].cost_usd - 20_100.0 * 0.2 / 1e6).abs() < 1e-12, "{}", c["other"].cost_usd);
829 let prompt_cost = t.summary().cost_breakdown.cache_read;
830 let attributed: f64 = c.values().map(|x| x.cost_usd).sum();
831 assert!((attributed - prompt_cost).abs() < 1e-12, "sources sum to the prompt-side cost");
832
833 writeln!(f, r#"{{"type":"system","subtype":"compact_boundary","timestamp":"2026-09-03T07:10:00.000Z","compactMetadata":{{"trigger":"auto","preTokens":14100,"postTokens":3000}}}}"#).unwrap();
836 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:10:01.000Z","isCompactSummary":true,"message":{{"role":"user","content":"summary"}}}}"#).unwrap();
837 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();
838 t.refresh().unwrap();
839 let c = by_name(t.summary());
840 assert_eq!(c["other"].tokens, 13_100);
841 assert!((c["Read"].cost_usd - 2_000.0 * 0.2 / 1e6).abs() < 1e-12, "not charged after the compaction");
842 let _ = std::fs::remove_dir_all(&dir);
843 }
844
845 #[test]
846 fn reconstructs_turns_inferences_and_web_searches() {
847 let dir = std::env::temp_dir().join(format!("agent-top-claude-turns-{}", std::process::id()));
848 std::fs::create_dir_all(&dir).unwrap();
849 let path = dir.join("s.jsonl");
850 let mut f = std::fs::File::create(&path).unwrap();
851 let usage = r#"{"input_tokens":100,"output_tokens":10,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5}}"#;
852 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"look it up"}}}}"#)
854 .unwrap();
855 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();
856 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();
857 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:05.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}}]}}}}"#).unwrap();
859 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();
860 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
861 t.refresh().unwrap();
862 let s = t.summary();
863 assert_eq!(s.web_searches, 2);
865 assert!((s.cost_usd - (0.000300 + 0.02 + 0.000012)).abs() < 1e-9, "{}", s.cost_usd);
867 let by_kind = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).cloned().collect::<Vec<_>>();
868 let turns = by_kind(SpanKind::Turn);
869 assert_eq!(turns.len(), 1);
870 assert_eq!(turns[0].duration_ms, Some(9_000), "prompt at :00, reply ended at :09");
871 let inf = by_kind(SpanKind::Inference);
872 assert_eq!(inf.len(), 2);
873 assert_eq!(inf[0].duration_ms, Some(4_000), "prompt at :00, last block of the reply at :04");
874 assert_eq!(inf[1].duration_ms, Some(4_000), "tool result at :05, reply at :09");
875 assert_eq!(by_kind(SpanKind::Tool)[0].duration_ms, Some(1_000));
876 let kinds: Vec<_> = s.spans.iter().map(|sp| sp.kind).collect();
878 assert_eq!(kinds, vec![SpanKind::Turn, SpanKind::Inference, SpanKind::Tool, SpanKind::Inference]);
879 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:00.000Z","message":{{"role":"user","content":"thanks"}}}}"#).unwrap();
881 t.refresh().unwrap();
882 let turns = by_kind_of(t.summary(), SpanKind::Turn);
883 assert_eq!(turns.len(), 2);
884 assert_eq!(turns[0].duration_ms, Some(9_000));
885 assert!(turns[1].is_open());
886 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();
891 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:06.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
892 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T07:00:00.000Z","message":{{"role":"user","content":"next day"}}}}"#)
893 .unwrap();
894 t.refresh().unwrap();
895 let turns = by_kind_of(t.summary(), SpanKind::Turn);
896 assert_eq!(turns.len(), 3);
897 assert_eq!(turns[1].duration_ms, Some(6_000), "turn 2: :01:00 to the interrupted tool result at :01:06");
898 assert!(turns[2].is_open());
899 let inf = by_kind_of(t.summary(), SpanKind::Inference);
900 assert_eq!(inf.iter().filter(|s| s.is_open()).count(), 1, "only the newest inference is open");
901 assert_eq!(inf.last().unwrap().started_at, turns[2].started_at);
902 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();
906 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:00:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
907 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();
908 t.refresh().unwrap();
909 let turns = by_kind_of(t.summary(), SpanKind::Turn);
910 assert_eq!(turns.len(), 3, "a meta line is not a prompt");
911 assert_eq!(turns[2].duration_ms, Some(2_000));
912 let inf = by_kind_of(t.summary(), SpanKind::Inference);
913 let last_two: Vec<_> = inf.iter().rev().take(2).map(|s| s.duration_ms).collect();
914 assert_eq!(last_two, vec![Some(3_000), Some(2_000)], "the command's reply is its own 3 s inference");
915 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:30:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
918 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();
919 t.refresh().unwrap();
920 let inf = by_kind_of(t.summary(), SpanKind::Inference);
921 assert!(inf.last().unwrap().is_open(), "the meta line's inference has no real reply yet");
922 assert_eq!(by_kind_of(t.summary(), SpanKind::Turn)[2].duration_ms, Some(2_000));
923 let _ = std::fs::remove_dir_all(&dir);
924 }
925
926 fn by_kind_of(s: &SessionSummary, k: SpanKind) -> Vec<crate::model::ToolSpan> {
927 s.spans.iter().filter(|sp| sp.kind == k).cloned().collect()
928 }
929
930 #[test]
931 fn folds_subagent_transcripts_into_the_parent() {
932 let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
933 let _ = std::fs::remove_dir_all(&dir);
934 std::fs::create_dir_all(&dir).unwrap();
935 let path = dir.join("s.jsonl");
936 let mut f = std::fs::File::create(&path).unwrap();
937 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();
939 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
940 t.refresh().unwrap();
941 assert_eq!(t.summary().usage.total(), 110);
942 assert_eq!(t.summary().subagent_turns, 0);
943 assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
945
946 let sub = subagents_dir(&path).unwrap();
948 std::fs::create_dir_all(&sub).unwrap();
949 let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
950 std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
951 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();
952 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();
953 t.refresh().unwrap();
954 let s = t.summary();
955 assert_eq!(s.usage.total(), 1210);
956 assert_eq!(s.turns, 2);
957 assert_eq!(s.subagent_turns, 1);
958 assert_eq!(s.tool_calls, 2);
959 assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
961 assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
962 assert_eq!(s.session_id.as_deref(), Some("abc"));
963 let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
964 assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
965 let spans: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
966 assert_eq!(spans.len(), 2);
967 assert_eq!(spans[0].name, "Agent");
968 assert!(spans[0].is_open());
969 assert_eq!(spans[1].name, "Grep");
970 assert!(spans[1].sidechain);
971 assert_eq!(spans[1].duration_ms, Some(2_000));
972 let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
975 assert_eq!(inf.len(), 1);
976 assert!(inf[0].sidechain);
977 assert!(inf[0].is_open());
978
979 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();
981 t.refresh().unwrap();
982 assert_eq!(t.summary().usage.total(), 1212);
983 assert_eq!(t.summary().subagent_turns, 2);
984 let _ = std::fs::remove_dir_all(&dir);
985 }
986
987 #[test]
988 fn builds_spans_from_tool_use_and_tool_result() {
989 let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
990 std::fs::create_dir_all(&dir).unwrap();
991 let path = dir.join("s.jsonl");
992 let mut f = std::fs::File::create(&path).unwrap();
993 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();
994 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();
996 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();
998 let mut t = ClaudeTranscript::new(&path);
999 t.refresh().unwrap();
1000 let all = t.summary().spans.to_vec();
1001 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
1005 assert_eq!(inf.len(), 1);
1006 assert_eq!(inf[0].name, "inference");
1007 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no prompt line, so no turn");
1008 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).cloned().collect();
1009 assert_eq!(spans.len(), 3);
1010 assert_eq!(spans[0].name, "Bash");
1011 assert_eq!(spans[0].duration_ms, Some(2_500));
1012 assert!(!spans[0].error);
1013 assert_eq!(spans[1].name, "Read");
1014 assert_eq!(spans[1].duration_ms, Some(2_500));
1015 assert!(spans[1].error);
1016 assert!(spans[2].is_open());
1017 assert!(spans[2].sidechain);
1018 assert_eq!(t.summary().tool_calls, 3);
1019 let _ = std::fs::remove_dir_all(&dir);
1020 }
1021}