1use super::{AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanRetention, parse_rfc3339_utc};
24use crate::jsonl::TailReader;
25use crate::model::{Activity, Attribution, Harness, ProcNode, SpanKind, TokenUsage};
26use crate::pricing::{self, Table};
27use crate::process::RawProc;
28use serde_json::Value;
29use std::collections::{HashMap, HashSet};
30use std::path::{Path, PathBuf};
31use std::time::{Duration, SystemTime};
32
33pub fn codex_dir() -> Option<PathBuf> {
34 if let Some(d) = std::env::var_os("CODEX_HOME") {
35 return Some(PathBuf::from(d));
36 }
37 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex"))
38}
39
40pub fn sessions_dir() -> Option<PathBuf> {
41 codex_dir().map(|d| d.join("sessions"))
42}
43
44pub fn rollouts_open_by(pid: u32) -> Option<Vec<PathBuf>> {
53 let root = sessions_dir()?;
54 let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
55 let open = crate::openfiles::open_files(pid)?;
56 Some(
57 open.into_iter()
58 .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
59 .filter_map(|p| p.strip_prefix(&canonical).ok().map(|rel| root.join(rel)))
60 .collect(),
61 )
62}
63
64pub fn recent_rollouts(since: SystemTime) -> Vec<PathBuf> {
66 let Some(root) = sessions_dir() else { return Vec::new() };
67 rollouts_under(&root, since)
68}
69
70pub(crate) fn rollouts_under(root: &Path, since: SystemTime) -> Vec<PathBuf> {
79 let mut out = Vec::new();
80 walk(root, 0, since, &mut out);
81 out
82}
83
84fn walk(dir: &Path, depth: usize, since: SystemTime, out: &mut Vec<PathBuf>) {
85 let Ok(rd) = std::fs::read_dir(dir) else { return };
86 for e in rd.flatten() {
87 let p = e.path();
88 let Ok(md) = e.metadata() else { continue };
89 if md.is_dir() {
90 if depth < 3 {
91 walk(&p, depth + 1, since, out);
92 }
93 } else if p.extension().and_then(|x| x.to_str()) == Some("jsonl") && md.modified().map(|m| m >= since).unwrap_or(false) {
94 out.push(p);
95 }
96 }
97}
98
99pub fn read_meta(path: &Path) -> Option<(PathBuf, SystemTime)> {
101 use std::io::{BufRead, BufReader};
102 let f = std::fs::File::open(path).ok()?;
103 let mut first = String::new();
104 BufReader::new(f).read_line(&mut first).ok()?;
105 let v: Value = serde_json::from_str(&first).ok()?;
106 if v.get("type").and_then(Value::as_str) != Some("session_meta") {
107 return None;
108 }
109 let cwd = v.pointer("/payload/cwd").and_then(Value::as_str).map(PathBuf::from)?;
110 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc)?;
111 Some((cwd, ts))
112}
113
114#[derive(Default)]
118pub struct CodexAdapter {
119 recent: Vec<(PathBuf, PathBuf, SystemTime)>,
121 held: HashMap<u32, Option<Vec<PathBuf>>>,
125 all_held: HashSet<PathBuf>,
126}
127
128impl HarnessAdapter for CodexAdapter {
129 fn harness(&self) -> Harness {
130 Harness::Codex
131 }
132
133 fn rescan(&mut self, since: SystemTime) {
134 self.recent = recent_rollouts(since).into_iter().filter_map(|p| read_meta(&p).map(|(cwd, ts)| (p, cwd, ts))).collect();
135 }
136
137 fn prepare(&mut self, roots: &[&ProcNode]) {
138 self.held = roots.iter().map(|r| (r.pid, rollouts_open_by(r.pid))).collect();
139 self.all_held = self.held.values().flatten().flatten().cloned().collect();
140 }
141
142 fn attribute(&self, root: &ProcNode, _raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
143 let mine: Option<Vec<PathBuf>> =
144 self.held.get(&root.pid).and_then(|h| h.as_ref()).map(|h| h.iter().filter(|p| !ctx.attached.contains(*p)).cloned().collect());
145 let taken: HashSet<PathBuf> = ctx.attached.union(&self.all_held).cloned().collect();
146 attribute(ctx.cwd, ctx.proc_start, mine.as_deref(), &self.recent, &taken, ctx.now, ctx.activity_timeout)
147 }
148
149 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
150 self.recent.iter().map(|(p, _, _)| p).filter(|p| !attached.contains(*p)).cloned().collect()
151 }
152
153 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
154 Box::new(CodexTranscript::new(path).with_spans(spans))
155 }
156
157 fn detect(&self, path: &Path) -> bool {
159 super::head_lines(path).iter().any(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
160 }
161
162 fn transcripts(&self) -> Vec<(String, PathBuf)> {
163 recent_rollouts(SystemTime::UNIX_EPOCH).into_iter().map(|p| (rollout_id(&p), p)).collect()
164 }
165}
166
167pub fn rollout_id(p: &Path) -> String {
170 const TS_LEN: usize = "2026-05-14T21-37-50-".len();
171 let stem = p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
172 stem.strip_prefix("rollout-").and_then(|s| s.get(TS_LEN..)).map(str::to_string).unwrap_or(stem)
173}
174
175pub(crate) fn attribute(
197 cwd: Option<&Path>,
198 proc_start: SystemTime,
199 held: Option<&[PathBuf]>,
200 recent: &[(PathBuf, PathBuf, SystemTime)],
201 taken: &HashSet<PathBuf>,
202 now: SystemTime,
203 activity_timeout: Duration,
204) -> (Vec<PathBuf>, Attribution) {
205 if let Some(held) = held {
206 let mut mine = held.to_vec();
207 mine.sort_by_key(|p| std::cmp::Reverse(written_at(p)));
208 mine.truncate(MAX_THREADS);
209 let attribution = if mine.is_empty() { Attribution::None } else { Attribution::OpenFile };
210 return (mine, attribution);
211 }
212
213 let slack = Duration::from_secs(60);
214 let started_after = |ts: &SystemTime| *ts + slack >= proc_start;
215 let candidates = || recent.iter().filter(|(p, _, ts)| started_after(ts) && !taken.contains(p));
216
217 if let Some(cwd) = cwd {
219 let mut matched: Vec<&(PathBuf, PathBuf, SystemTime)> = candidates().filter(|(_, c, _)| c == cwd).collect();
220 if !matched.is_empty() {
221 matched.sort_by_key(|(p, _, _)| std::cmp::Reverse(written_at(p)));
222 return (matched.into_iter().map(|(p, _, _)| p.clone()).collect(), Attribution::CwdHeuristic);
223 }
224 }
225
226 let mut live: Vec<&(PathBuf, PathBuf, SystemTime)> = candidates()
230 .filter(|(p, _, _)| written_at(p).map(|w| now.duration_since(w).unwrap_or_default() <= activity_timeout).unwrap_or(false))
231 .collect();
232 live.sort_by_key(|(p, _, _)| std::cmp::Reverse(written_at(p)));
233 live.truncate(MAX_THREADS);
234 let attribution = if live.is_empty() { Attribution::None } else { Attribution::CwdHeuristic };
235 (live.into_iter().map(|(p, _, _)| p.clone()).collect(), attribution)
236}
237
238const MAX_THREADS: usize = 12;
241
242fn written_at(p: &Path) -> Option<SystemTime> {
243 std::fs::metadata(p).and_then(|m| m.modified()).ok()
244}
245
246pub struct CodexTranscript {
247 reader: TailReader,
248 prices: &'static Table,
249 summary: SessionSummary,
250 turns: u64,
253 inferences: u64,
254 turn: Option<String>,
255 inference: Option<String>,
256}
257
258impl CodexTranscript {
259 pub fn new(path: impl Into<PathBuf>) -> Self {
260 CodexTranscript {
261 reader: TailReader::new(path),
262 prices: pricing::table(),
263 summary: SessionSummary { harness: Some(Harness::Codex), ..Default::default() },
264 turns: 0,
265 inferences: 0,
266 turn: None,
267 inference: None,
268 }
269 }
270
271 fn begin_inference(&mut self, ts: SystemTime) {
274 if self.summary.spans.open_of_kind(SpanKind::Inference).is_some() {
275 return;
276 }
277 if let Some(id) = self.inference.take() {
280 self.summary.spans.discard_open(&id);
281 }
282 self.inferences += 1;
283 let id = format!("inference:{}", self.inferences);
284 self.summary.spans.open_kind(id.clone(), "inference".into(), ts, false, SpanKind::Inference);
285 self.inference = Some(id);
286 }
287
288 fn end_inference(&mut self, ts: SystemTime) {
290 if let Some(id) = self.inference.take() {
291 self.summary.spans.end_at(&id, ts);
292 }
293 }
294
295 pub fn with_prices(mut self, prices: &'static Table) -> Self {
297 self.prices = prices;
298 self
299 }
300
301 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
303 self.summary.spans = retention.log();
304 self
305 }
306
307 fn ingest(&mut self, line: &str) {
308 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
309 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
310 if let Some(ts) = ts {
311 if self.summary.started_at.is_none() {
312 self.summary.started_at = Some(ts);
313 }
314 self.summary.last_activity = Some(ts);
315 }
316 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
317 let payload = v.get("payload");
318 let ptype = payload.and_then(|p| p.get("type")).and_then(Value::as_str).unwrap_or("");
319 match kind {
320 "session_meta" => {
321 if let Some(p) = payload {
322 self.summary.session_id = p.get("id").or(p.get("session_id")).and_then(Value::as_str).map(str::to_string);
323 self.summary.cwd = p.get("cwd").and_then(Value::as_str).map(PathBuf::from);
324 self.summary.harness_version = p.get("cli_version").and_then(Value::as_str).map(str::to_string);
325 }
326 }
327 "turn_context" => {
328 if let Some(m) = payload.and_then(|p| p.get("model")).and_then(Value::as_str) {
329 self.summary.model = Some(m.to_string());
330 }
331 }
332 "event_msg" => match ptype {
333 "token_count" => {
334 if let Some(total) = payload.and_then(|p| p.pointer("/info/total_token_usage")) {
335 let g = |k: &str| total.get(k).and_then(Value::as_u64).unwrap_or(0);
336 self.summary.health.usage_records += 1;
337 if g("input_tokens") + g("output_tokens") + g("cached_input_tokens") == 0 {
338 self.summary.health.empty_usage_records += 1;
339 }
340 let cached = g("cached_input_tokens");
341 let usage = TokenUsage {
342 input: g("input_tokens").saturating_sub(cached),
343 cache_read: cached,
344 output: g("output_tokens"),
345 ..Default::default()
346 };
347 self.summary.usage = usage;
348 let price = self.summary.model.as_deref().and_then(|m| self.prices.lookup(m));
349 match price {
350 Some(p) => {
351 self.summary.cost_breakdown = p.breakdown(&usage);
352 self.summary.cost_usd = self.summary.cost_breakdown.total();
353 self.summary.unpriced_tokens = 0;
354 }
355 None => {
356 self.summary.cost_breakdown = Default::default();
357 self.summary.cost_usd = 0.0;
358 self.summary.unpriced_tokens = usage.total();
359 }
360 }
361 }
362 }
363 "task_started" => {
364 self.summary.activity = Activity::Working;
365 if let Some(ts) = ts {
366 self.turns += 1;
367 let id = format!("turn:{}", self.turns);
368 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, false, SpanKind::Turn);
369 self.turn = Some(id);
370 }
371 }
372 "user_message" => self.summary.activity = Activity::Working,
373 "mcp_tool_call_end" => {
382 if let Some(inv) = payload.and_then(|p| p.get("invocation"))
383 && let Some(server) = inv.get("server").and_then(Value::as_str).filter(|s| !s.is_empty())
384 {
385 let error = payload
386 .and_then(|p| p.get("result"))
387 .and_then(Value::as_object)
388 .map(|r| !r.contains_key("Ok"))
389 .unwrap_or(false);
390 let u = self.summary.mcp.entry(server.to_string()).or_default();
391 u.calls += 1;
392 u.errors += u64::from(error);
393 u.last_call = u.last_call.max(ts);
394 }
395 }
396 "task_complete" | "turn_aborted" | "error" => {
397 self.summary.activity = Activity::Waiting;
398 if let (Some(ts), Some(id)) = (ts, self.turn.take()) {
399 self.summary.spans.end_at(&id, ts);
400 }
401 if let Some(id) = self.inference.take() {
402 self.summary.spans.discard_open(&id);
403 }
404 }
405 _ => {}
406 },
407 "response_item" => match ptype {
408 "function_call" | "custom_tool_call" | "local_shell_call" => {
409 self.summary.tool_calls += 1;
410 if let (Some(ts), Some(p)) = (ts, payload) {
411 self.end_inference(ts);
412 let id = call_id(p);
413 let name = p.get("name").and_then(Value::as_str).unwrap_or(ptype);
414 self.summary.spans.open(id, name.to_string(), ts, false);
415 }
416 }
417 "function_call_output" | "custom_tool_call_output" | "local_shell_call_output" => {
418 if let (Some(ts), Some(p)) = (ts, payload) {
419 self.summary.spans.close(&call_id(p), ts, false);
423 self.begin_inference(ts);
424 }
425 }
426 "web_search_call" => {
429 self.summary.web_searches += 1;
430 if let Some(ts) = ts {
431 self.end_inference(ts);
432 }
433 }
434 "reasoning" => {
435 if let Some(ts) = ts {
436 self.end_inference(ts);
437 }
438 }
439 "message" => match payload.and_then(|p| p.get("role")).and_then(Value::as_str) {
440 Some("assistant") => {
441 self.summary.turns += 1;
442 self.summary.health.billable_messages += 1;
443 if let Some(ts) = ts {
444 self.end_inference(ts);
445 }
446 }
447 Some("user") => {
448 if let Some(ts) = ts {
449 self.begin_inference(ts);
450 }
451 }
452 _ => {}
453 },
454 _ => {}
455 },
456 _ => {}
457 }
458 }
459}
460
461fn call_id(payload: &Value) -> String {
463 payload.get("call_id").or_else(|| payload.get("id")).and_then(Value::as_str).unwrap_or_default().to_string()
464}
465
466impl SessionTracker for CodexTranscript {
467 fn refresh(&mut self) -> anyhow::Result<bool> {
468 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
469 for l in &lines {
470 self.ingest(l);
471 }
472 Ok(more)
473 }
474
475 fn summary(&self) -> &SessionSummary {
476 &self.summary
477 }
478
479 fn path(&self) -> &Path {
480 self.reader.path()
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use std::io::Write;
488 use std::time::Duration;
489
490 fn rollout(dir: &Path, name: &str, written: SystemTime) -> PathBuf {
500 let p = dir.join(name);
501 std::fs::write(&p, b"x").unwrap();
502 let f = std::fs::File::options().write(true).open(&p).unwrap();
503 f.set_times(std::fs::FileTimes::new().set_accessed(written).set_modified(written)).unwrap();
504 p
505 }
506
507 const TIMEOUT: Duration = Duration::from_secs(15 * 60);
508
509 #[test]
513 fn every_live_codex_thread_is_returned_newest_first() {
514 let dir = std::env::temp_dir().join(format!("agent-top-threads-{}", std::process::id()));
515 let _ = std::fs::remove_dir_all(&dir);
516 std::fs::create_dir_all(&dir).unwrap();
517 let now = SystemTime::now();
518 let started = now - Duration::from_secs(600);
519
520 let a = rollout(&dir, "a.jsonl", now - Duration::from_secs(300));
523 let b = rollout(&dir, "b.jsonl", now - Duration::from_secs(200));
524 let c = rollout(&dir, "c.jsonl", now - Duration::from_secs(100));
525 let recent: Vec<(PathBuf, PathBuf, SystemTime)> =
526 [&a, &b, &c].iter().map(|p| ((*p).clone(), PathBuf::from("/Users/dev/code/one"), started)).collect();
527
528 let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &HashSet::new(), now, TIMEOUT);
530 assert_eq!(paths.len(), 3, "all three conversations get a row");
531 assert_eq!(paths[0], c, "newest activity first");
532 assert_eq!(attribution, Attribution::CwdHeuristic, "still a heuristic, and still labelled one");
533
534 let taken: HashSet<PathBuf> = [c.clone()].into_iter().collect();
536 let (paths, _) = attribute(Some(Path::new("/")), started, None, &recent, &taken, now, TIMEOUT);
537 assert_eq!(paths.len(), 2);
538 assert!(!paths.contains(&c));
539
540 let stale = now + TIMEOUT + Duration::from_secs(60);
544 let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &HashSet::new(), stale, TIMEOUT);
545 assert!(paths.is_empty());
546 assert_eq!(attribution, Attribution::None);
547
548 let (paths, _) = attribute(Some(Path::new("/Users/dev/code/one")), started, None, &recent, &HashSet::new(), now, TIMEOUT);
550 assert_eq!(paths.len(), 3, "a cwd match takes every conversation in that directory");
551 assert_eq!(paths[0], c);
552
553 let (paths, _) = attribute(Some(Path::new("/")), now + Duration::from_secs(3600), None, &recent, &HashSet::new(), now, TIMEOUT);
555 assert!(paths.is_empty());
556
557 let _ = std::fs::remove_dir_all(&dir);
558 }
559
560 #[test]
566 fn an_open_rollout_belongs_to_the_process_holding_it() {
567 let dir = std::env::temp_dir().join(format!("agent-top-held-{}", std::process::id()));
568 let _ = std::fs::remove_dir_all(&dir);
569 std::fs::create_dir_all(&dir).unwrap();
570 let now = SystemTime::now();
571 let started = now - Duration::from_secs(600);
572 let a = rollout(&dir, "a.jsonl", now - Duration::from_secs(200));
573 let b = rollout(&dir, "b.jsonl", now - Duration::from_secs(100));
574 let recent: Vec<(PathBuf, PathBuf, SystemTime)> =
575 [&a, &b].iter().map(|p| ((*p).clone(), PathBuf::from("/Users/dev/code/one"), started)).collect();
576
577 let held = vec![a.clone(), b.clone()];
581 let (paths, attribution) = attribute(Some(Path::new("/")), now, Some(&held), &recent, &HashSet::new(), now, TIMEOUT);
582 assert_eq!(paths, vec![b.clone(), a.clone()], "held rollouts, newest written first");
583 assert_eq!(attribution, Attribution::OpenFile);
584
585 let taken: HashSet<PathBuf> = held.iter().cloned().collect();
588 let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &taken, now, TIMEOUT);
589 assert!(paths.is_empty());
590 assert_eq!(attribution, Attribution::None);
591
592 let _ = std::fs::remove_dir_all(&dir);
593 }
594
595 #[test]
596 fn the_rollout_id_follows_the_timestamp() {
597 assert_eq!(
598 rollout_id(Path::new("/x/2026/05/14/rollout-2026-05-14T21-37-50-01000000-0000-7000-0000-000000000000.jsonl")),
599 "01000000-0000-7000-0000-000000000000"
600 );
601 assert_eq!(rollout_id(Path::new("/x/odd.jsonl")), "odd");
602 }
603
604 #[test]
605 fn finds_a_fresh_rollout_under_stale_directories() {
606 let root = std::env::temp_dir().join(format!("agent-top-rollouts-{}", std::process::id()));
607 let day = root.join("2026").join("09").join("04");
608 std::fs::create_dir_all(&day).unwrap();
609 let fresh = day.join("rollout-fresh.jsonl");
610 let stale = day.join("rollout-stale.jsonl");
611 std::fs::write(&fresh, "{}\n").unwrap();
612 std::fs::write(&stale, "{}\n").unwrap();
613 let now = SystemTime::now();
614 let long_ago = now - Duration::from_secs(40 * 86_400);
615 std::fs::File::open(&stale).unwrap().set_modified(long_ago).unwrap();
616 for dir in [&root, &root.join("2026"), &root.join("2026").join("09"), &day] {
617 std::fs::File::open(dir).unwrap().set_modified(long_ago).unwrap();
618 }
619 let found = rollouts_under(&root, now - Duration::from_secs(1800));
620 assert_eq!(found, vec![fresh], "the fresh file is found through directories nobody has touched in weeks");
621 std::fs::remove_dir_all(&root).unwrap();
622 }
623
624 #[test]
625 fn counts_mcp_calls_per_server_from_the_end_event() {
626 let dir = std::env::temp_dir().join(format!("agent-top-codex-mcp-{}", std::process::id()));
627 std::fs::create_dir_all(&dir).unwrap();
628 let path = dir.join("rollout.jsonl");
629 let mut f = std::fs::File::create(&path).unwrap();
630 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:01.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"c1","name":"github_fetch_file"}}}}"#).unwrap();
634 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"c1"}}}}"#).unwrap();
635 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.100Z","type":"event_msg","payload":{{"type":"mcp_tool_call_end","call_id":"c1","invocation":{{"server":"codex_apps","tool":"github_fetch_file"}},"duration":{{"secs":1,"nanos":0}},"result":{{"Ok":{{}}}}}}}}"#).unwrap();
636 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:05.000Z","type":"event_msg","payload":{{"type":"mcp_tool_call_end","call_id":"c2","invocation":{{"server":"codex_apps","tool":"github_search"}},"duration":{{"secs":0,"nanos":0}},"result":{{"Err":"boom"}}}}}}"#).unwrap();
637 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:07.000Z","type":"event_msg","payload":{{"type":"mcp_tool_call_end","call_id":"c3","invocation":{{"server":"node_repl","tool":"js"}},"duration":{{"secs":0,"nanos":0}},"result":{{"Ok":{{}}}}}}}}"#).unwrap();
638 let mut t = CodexTranscript::new(&path);
639 t.refresh().unwrap();
640 let s = t.summary();
641 assert_eq!(s.mcp.len(), 2);
642 let apps = &s.mcp["codex_apps"];
643 assert_eq!((apps.calls, apps.errors), (2, 1));
644 assert_eq!(apps.last_call, parse_rfc3339_utc("2026-05-27T09:00:05.000Z"));
645 assert_eq!(s.mcp["node_repl"].calls, 1);
646 assert_eq!(s.tool_calls, 1, "mcp_tool_call_end must not double-count tool calls");
649 let _ = std::fs::remove_dir_all(&dir);
650 }
651
652 #[test]
653 fn reads_cumulative_usage_and_state() {
654 let dir = std::env::temp_dir().join(format!("agent-top-codex-{}", std::process::id()));
655 std::fs::create_dir_all(&dir).unwrap();
656 let path = dir.join("rollout.jsonl");
657 let mut f = std::fs::File::create(&path).unwrap();
658 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:20.787Z","type":"session_meta","payload":{{"id":"01a0","cwd":"/tmp/p","cli_version":"0.149.1"}}}}"#).unwrap();
659 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:21.000Z","type":"turn_context","payload":{{"model":"gpt-5-codex"}}}}"#).unwrap();
660 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:22.000Z","type":"event_msg","payload":{{"type":"task_started"}}}}"#).unwrap();
661 writeln!(
662 f,
663 r#"{{"timestamp":"2026-08-28T08:53:23.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"call_1","name":"shell"}}}}"#
664 )
665 .unwrap();
666 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:24.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":14778,"cached_input_tokens":12672,"output_tokens":241,"total_tokens":15019}}}}}}}}"#).unwrap();
667 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.000Z","type":"event_msg","payload":{{"type":"token_count","info":null}}}}"#)
668 .unwrap();
669 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.500Z","type":"response_item","payload":{{"type":"web_search_call","status":"completed"}}}}"#).unwrap();
670 let mut t = CodexTranscript::new(&path);
671 t.refresh().unwrap();
672 let s = t.summary();
673 assert_eq!(s.session_id.as_deref(), Some("01a0"));
674 assert_eq!(s.model.as_deref(), Some("gpt-5-codex"));
675 assert_eq!(s.usage.input, 14778 - 12672);
676 assert_eq!(s.usage.cache_read, 12672);
677 assert_eq!(s.usage.total(), 15019);
678 assert_eq!(s.unpriced_tokens, 15019);
679 assert_eq!(s.tool_calls, 1);
680 assert_eq!(s.activity, Activity::Working);
681 assert_eq!(read_meta(&path).unwrap().0, PathBuf::from("/tmp/p"));
682 assert_eq!(s.web_searches, 1);
683 let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
684 assert_eq!(tools.len(), 1);
685 assert_eq!(tools[0].name, "shell");
686 assert!(tools[0].is_open(), "no output item yet");
687 let turns: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Turn).collect();
688 assert_eq!(turns.len(), 1);
689 assert!(turns[0].is_open(), "task_started with no task_complete");
690 let _ = std::fs::remove_dir_all(&dir);
691 }
692
693 #[test]
694 fn pairs_calls_with_their_outputs() {
695 let dir = std::env::temp_dir().join(format!("agent-top-codex-spans-{}", std::process::id()));
696 std::fs::create_dir_all(&dir).unwrap();
697 let path = dir.join("rollout.jsonl");
698 let mut f = std::fs::File::create(&path).unwrap();
699 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:23.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"call_1","name":"exec_command"}}}}"#).unwrap();
700 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:23.100Z","type":"response_item","payload":{{"type":"custom_tool_call","call_id":"call_2","name":"apply_patch"}}}}"#).unwrap();
701 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:24.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"call_1","output":"ok"}}}}"#).unwrap();
702 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:26.100Z","type":"response_item","payload":{{"type":"custom_tool_call_output","call_id":"call_2","output":"ok"}}}}"#).unwrap();
703 writeln!(
705 f,
706 r#"{{"timestamp":"2026-08-28T08:53:27.600Z","type":"response_item","payload":{{"type":"message","role":"assistant"}}}}"#
707 )
708 .unwrap();
709 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:27.700Z","type":"event_msg","payload":{{"type":"task_complete"}}}}"#).unwrap();
710 let mut t = CodexTranscript::new(&path);
711 t.refresh().unwrap();
712 let all = t.summary().spans.to_vec();
713 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
714 assert_eq!(spans.len(), 2);
715 assert_eq!(spans[0].name, "exec_command");
716 assert_eq!(spans[0].duration_ms, Some(1_000));
717 assert_eq!(spans[1].name, "apply_patch");
718 assert_eq!(spans[1].duration_ms, Some(3_000));
719 assert_eq!(t.summary().tool_calls, 2);
720 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
723 assert_eq!(inf.len(), 1);
724 assert_eq!(inf[0].duration_ms, Some(3_600));
725 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no task_started in this file");
726 let _ = std::fs::remove_dir_all(&dir);
727 }
728}