1use super::{AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanRetention, parse_rfc3339_utc};
33use crate::jsonl::TailReader;
34use crate::model::{Activity, Attribution, ContextOrigin, Harness, ProcNode, SpanKind, TokenUsage};
35use crate::pricing::{self, Table};
36use crate::process::RawProc;
37use serde_json::Value;
38use std::collections::{HashMap, HashSet};
39use std::path::{Path, PathBuf};
40use std::time::{Duration, SystemTime};
41
42pub fn codex_dir() -> Option<PathBuf> {
43 if let Some(d) = std::env::var_os("CODEX_HOME") {
44 return Some(PathBuf::from(d));
45 }
46 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex"))
47}
48
49pub fn sessions_dir() -> Option<PathBuf> {
50 codex_dir().map(|d| d.join("sessions"))
51}
52
53pub fn rollouts_open_by(pid: u32) -> Option<Vec<PathBuf>> {
62 let root = sessions_dir()?;
63 let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
64 let open = crate::openfiles::open_files(pid)?;
65 Some(
66 open.into_iter()
67 .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
68 .filter_map(|p| p.strip_prefix(&canonical).ok().map(|rel| root.join(rel)))
69 .collect(),
70 )
71}
72
73pub fn recent_rollouts(since: SystemTime) -> Vec<PathBuf> {
75 let Some(root) = sessions_dir() else { return Vec::new() };
76 rollouts_under(&root, since)
77}
78
79pub(crate) fn rollouts_under(root: &Path, since: SystemTime) -> Vec<PathBuf> {
88 let mut out = Vec::new();
89 walk(root, 0, since, &mut out);
90 out
91}
92
93fn walk(dir: &Path, depth: usize, since: SystemTime, out: &mut Vec<PathBuf>) {
94 let Ok(rd) = std::fs::read_dir(dir) else { return };
95 for e in rd.flatten() {
96 let p = e.path();
97 let Ok(md) = e.metadata() else { continue };
98 if md.is_dir() {
99 if depth < 3 {
100 walk(&p, depth + 1, since, out);
101 }
102 } else if p.extension().and_then(|x| x.to_str()) == Some("jsonl") && md.modified().map(|m| m >= since).unwrap_or(false) {
103 out.push(p);
104 }
105 }
106}
107
108pub fn read_meta(path: &Path) -> Option<(PathBuf, SystemTime)> {
110 use std::io::{BufRead, BufReader};
111 let f = std::fs::File::open(path).ok()?;
112 let mut first = String::new();
113 BufReader::new(f).read_line(&mut first).ok()?;
114 let v: Value = serde_json::from_str(&first).ok()?;
115 if v.get("type").and_then(Value::as_str) != Some("session_meta") {
116 return None;
117 }
118 let cwd = v.pointer("/payload/cwd").and_then(Value::as_str).map(PathBuf::from)?;
119 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc)?;
120 Some((cwd, ts))
121}
122
123#[derive(Default)]
127pub struct CodexAdapter {
128 recent: Vec<(PathBuf, PathBuf, SystemTime)>,
130 held: HashMap<u32, Option<Vec<PathBuf>>>,
134 all_held: HashSet<PathBuf>,
135}
136
137impl HarnessAdapter for CodexAdapter {
138 fn harness(&self) -> Harness {
139 Harness::Codex
140 }
141
142 fn rescan(&mut self, since: SystemTime) {
143 self.recent = recent_rollouts(since).into_iter().filter_map(|p| read_meta(&p).map(|(cwd, ts)| (p, cwd, ts))).collect();
144 }
145
146 fn prepare(&mut self, roots: &[&ProcNode]) {
147 self.held = roots.iter().map(|r| (r.pid, rollouts_open_by(r.pid))).collect();
148 self.all_held = self.held.values().flatten().flatten().cloned().collect();
149 }
150
151 fn attribute(&self, root: &ProcNode, _raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
152 let mine: Option<Vec<PathBuf>> =
153 self.held.get(&root.pid).and_then(|h| h.as_ref()).map(|h| h.iter().filter(|p| !ctx.attached.contains(*p)).cloned().collect());
154 let taken: HashSet<PathBuf> = ctx.attached.union(&self.all_held).cloned().collect();
155 attribute(ctx.cwd, ctx.proc_start, mine.as_deref(), &self.recent, &taken, ctx.now, ctx.activity_timeout)
156 }
157
158 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
159 self.recent.iter().map(|(p, _, _)| p).filter(|p| !attached.contains(*p)).cloned().collect()
160 }
161
162 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
163 Box::new(CodexTranscript::new(path).with_spans(spans))
164 }
165
166 fn detect(&self, path: &Path) -> bool {
168 super::head_lines(path).iter().any(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
169 }
170
171 fn transcripts(&self) -> Vec<(String, PathBuf)> {
172 recent_rollouts(SystemTime::UNIX_EPOCH).into_iter().map(|p| (rollout_id(&p), p)).collect()
173 }
174}
175
176pub fn rollout_id(p: &Path) -> String {
179 const TS_LEN: usize = "2026-05-14T21-37-50-".len();
180 let stem = p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
181 stem.strip_prefix("rollout-").and_then(|s| s.get(TS_LEN..)).map(str::to_string).unwrap_or(stem)
182}
183
184pub(crate) fn attribute(
206 cwd: Option<&Path>,
207 proc_start: SystemTime,
208 held: Option<&[PathBuf]>,
209 recent: &[(PathBuf, PathBuf, SystemTime)],
210 taken: &HashSet<PathBuf>,
211 now: SystemTime,
212 activity_timeout: Duration,
213) -> (Vec<PathBuf>, Attribution) {
214 if let Some(held) = held {
215 let mut mine = held.to_vec();
216 mine.sort_by_key(|p| std::cmp::Reverse(written_at(p)));
217 mine.truncate(MAX_THREADS);
218 let attribution = if mine.is_empty() { Attribution::None } else { Attribution::OpenFile };
219 return (mine, attribution);
220 }
221
222 let slack = Duration::from_secs(60);
223 let started_after = |ts: &SystemTime| *ts + slack >= proc_start;
224 let candidates = || recent.iter().filter(|(p, _, ts)| started_after(ts) && !taken.contains(p));
225
226 if let Some(cwd) = cwd {
228 let mut matched: Vec<&(PathBuf, PathBuf, SystemTime)> = candidates().filter(|(_, c, _)| c == cwd).collect();
229 if !matched.is_empty() {
230 matched.sort_by_key(|(p, _, _)| std::cmp::Reverse(written_at(p)));
231 return (matched.into_iter().map(|(p, _, _)| p.clone()).collect(), Attribution::CwdHeuristic);
232 }
233 }
234
235 let mut live: Vec<&(PathBuf, PathBuf, SystemTime)> = candidates()
239 .filter(|(p, _, _)| written_at(p).map(|w| now.duration_since(w).unwrap_or_default() <= activity_timeout).unwrap_or(false))
240 .collect();
241 live.sort_by_key(|(p, _, _)| std::cmp::Reverse(written_at(p)));
242 live.truncate(MAX_THREADS);
243 let attribution = if live.is_empty() { Attribution::None } else { Attribution::CwdHeuristic };
244 (live.into_iter().map(|(p, _, _)| p.clone()).collect(), attribution)
245}
246
247const MAX_THREADS: usize = 12;
250
251fn written_at(p: &Path) -> Option<SystemTime> {
252 std::fs::metadata(p).and_then(|m| m.modified()).ok()
253}
254
255pub struct CodexTranscript {
256 reader: TailReader,
257 prices: &'static Table,
258 summary: SessionSummary,
259 turns: u64,
262 inferences: u64,
263 turn: Option<String>,
264 inference: Option<String>,
265 pending_tools: HashMap<String, String>,
268 last_response: Option<TokenUsage>,
270}
271
272impl CodexTranscript {
273 pub fn new(path: impl Into<PathBuf>) -> Self {
274 CodexTranscript {
275 reader: TailReader::new(path),
276 prices: pricing::table(),
277 summary: SessionSummary { harness: Some(Harness::Codex), ..Default::default() },
278 turns: 0,
279 inferences: 0,
280 turn: None,
281 inference: None,
282 pending_tools: HashMap::new(),
283 last_response: None,
284 }
285 }
286
287 fn begin_inference(&mut self, ts: SystemTime) {
290 if self.summary.spans.open_of_kind(SpanKind::Inference).is_some() {
291 return;
292 }
293 if let Some(id) = self.inference.take() {
296 self.summary.spans.discard_open(&id);
297 }
298 self.inferences += 1;
299 let id = format!("inference:{}", self.inferences);
300 self.summary.spans.open_kind(id.clone(), "inference".into(), ts, false, SpanKind::Inference);
301 self.inference = Some(id);
302 }
303
304 fn end_inference(&mut self, ts: SystemTime) {
306 if let Some(id) = self.inference.take() {
307 self.summary.spans.end_at(&id, ts);
308 }
309 }
310
311 pub fn with_prices(mut self, prices: &'static Table) -> Self {
313 self.prices = prices;
314 self
315 }
316
317 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
319 self.summary.spans = retention.log();
320 self
321 }
322
323 fn ingest(&mut self, line: &str) {
324 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
325 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
326 if let Some(ts) = ts {
327 if self.summary.started_at.is_none() {
328 self.summary.started_at = Some(ts);
329 }
330 self.summary.last_activity = Some(ts);
331 }
332 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
333 let payload = v.get("payload");
334 let ptype = payload.and_then(|p| p.get("type")).and_then(Value::as_str).unwrap_or("");
335 match kind {
336 "session_meta" => {
337 if let Some(p) = payload {
338 self.summary.session_id = p.get("id").or(p.get("session_id")).and_then(Value::as_str).map(str::to_string);
339 self.summary.cwd = p.get("cwd").and_then(Value::as_str).map(PathBuf::from);
340 self.summary.harness_version = p.get("cli_version").and_then(Value::as_str).map(str::to_string);
341 }
342 }
343 "turn_context" => {
344 if let Some(m) = payload.and_then(|p| p.get("model")).and_then(Value::as_str) {
345 self.summary.model = Some(m.to_string());
346 }
347 }
348 "event_msg" => match ptype {
349 "token_count" => {
350 if let Some(total) = payload.and_then(|p| p.pointer("/info/total_token_usage")) {
351 let g = |k: &str| total.get(k).and_then(Value::as_u64).unwrap_or(0);
352 self.summary.health.usage_records += 1;
353 if g("input_tokens") + g("output_tokens") + g("cached_input_tokens") == 0 {
354 self.summary.health.empty_usage_records += 1;
355 }
356 let cached = g("cached_input_tokens");
357 let usage = TokenUsage {
358 input: g("input_tokens").saturating_sub(cached),
359 cache_read: cached,
360 output: g("output_tokens"),
361 ..Default::default()
362 };
363 self.summary.usage = usage;
364 let price = self.summary.model.as_deref().and_then(|m| self.prices.lookup(m));
365 match price {
366 Some(p) => {
367 self.summary.cost_breakdown = p.breakdown(&usage);
368 self.summary.cost_usd = self.summary.cost_breakdown.total();
369 self.summary.unpriced_tokens = 0;
370 }
371 None => {
372 self.summary.cost_breakdown = Default::default();
373 self.summary.cost_usd = 0.0;
374 self.summary.unpriced_tokens = usage.total();
375 }
376 }
377 }
378 if let Some(last) = payload.and_then(|p| p.pointer("/info/last_token_usage")) {
379 let g = |k: &str| last.get(k).and_then(Value::as_u64).unwrap_or(0);
380 let cached = g("cached_input_tokens");
381 let usage = TokenUsage {
382 input: g("input_tokens").saturating_sub(cached),
383 cache_read: cached,
384 output: g("output_tokens"),
385 ..Default::default()
386 };
387 if self.last_response != Some(usage) {
388 let cost = self
389 .summary
390 .model
391 .as_deref()
392 .and_then(|m| self.prices.lookup(m))
393 .map(|p| p.breakdown(&usage))
394 .unwrap_or_default();
395 self.summary.context.response(&usage, &cost);
396 self.last_response = Some(usage);
397 }
398 }
399 if let Some(rl) = payload.and_then(|p| p.get("rate_limits")).filter(|v| v.is_object()) {
402 self.summary.rate_limit = Some(parse_rate_limits(rl));
403 }
404 }
405 "task_started" => {
406 self.summary.activity = Activity::Working;
407 if let Some(ts) = ts {
408 self.turns += 1;
409 let id = format!("turn:{}", self.turns);
410 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, false, SpanKind::Turn);
411 self.turn = Some(id);
412 }
413 }
414 "user_message" => self.summary.activity = Activity::Working,
415 "mcp_tool_call_end" => {
424 if let Some(inv) = payload.and_then(|p| p.get("invocation"))
425 && let Some(server) = inv.get("server").and_then(Value::as_str).filter(|s| !s.is_empty())
426 {
427 let error = payload
428 .and_then(|p| p.get("result"))
429 .and_then(Value::as_object)
430 .map(|r| !r.contains_key("Ok"))
431 .unwrap_or(false);
432 let u = self.summary.mcp.entry(server.to_string()).or_default();
433 u.calls += 1;
434 u.errors += u64::from(error);
435 u.last_call = u.last_call.max(ts);
436 self.summary.context.retag(&payload.map(call_id).unwrap_or_default(), ContextOrigin::Mcp, server);
437 }
438 }
439 "task_complete" | "turn_aborted" | "error" => {
440 self.summary.activity = Activity::Waiting;
441 if let (Some(ts), Some(id)) = (ts, self.turn.take()) {
442 self.summary.spans.end_at(&id, ts);
443 }
444 if let Some(id) = self.inference.take() {
445 self.summary.spans.discard_open(&id);
446 }
447 }
448 _ => {}
449 },
450 "response_item" => match ptype {
451 "function_call" | "custom_tool_call" | "local_shell_call" => {
452 self.summary.tool_calls += 1;
453 if let (Some(ts), Some(p)) = (ts, payload) {
454 self.end_inference(ts);
455 let id = call_id(p);
456 let name = p.get("name").and_then(Value::as_str).unwrap_or(ptype);
457 self.pending_tools.insert(id.clone(), name.to_string());
458 self.summary.spans.open(id, name.to_string(), ts, false);
459 }
460 }
461 "function_call_output" | "custom_tool_call_output" | "local_shell_call_output" => {
462 if let (Some(ts), Some(p)) = (ts, payload) {
463 let id = call_id(p);
467 self.summary.spans.close(&id, ts, false);
468 let name = self.pending_tools.remove(&id).unwrap_or_else(|| "tool".into());
469 self.summary.context.result(&id, ContextOrigin::Tool, &name);
470 self.begin_inference(ts);
471 }
472 }
473 "web_search_call" => {
476 self.summary.web_searches += 1;
477 if let Some(ts) = ts {
478 self.end_inference(ts);
479 }
480 }
481 "reasoning" => {
482 if let Some(ts) = ts {
483 self.end_inference(ts);
484 }
485 }
486 "message" => match payload.and_then(|p| p.get("role")).and_then(Value::as_str) {
487 Some("assistant") => {
488 self.summary.turns += 1;
489 self.summary.health.billable_messages += 1;
490 if let Some(ts) = ts {
491 self.end_inference(ts);
492 }
493 }
494 Some("user") => {
495 if let Some(ts) = ts {
496 self.begin_inference(ts);
497 }
498 }
499 _ => {}
500 },
501 _ => {}
502 },
503 _ => {}
504 }
505 }
506}
507
508fn parse_rate_limits(v: &Value) -> crate::model::RateLimit {
512 use crate::model::{RateLimit, RateWindow};
513 let window = |w: Option<&Value>| -> Option<RateWindow> {
514 let w = w?;
515 Some(RateWindow {
516 used_percent: w.get("used_percent").and_then(Value::as_f64).unwrap_or(0.0),
517 window_minutes: w.get("window_minutes").and_then(Value::as_u64).unwrap_or(0),
518 resets_at: w
519 .get("resets_at")
520 .and_then(Value::as_i64)
521 .filter(|s| *s > 0)
522 .map(|s| std::time::UNIX_EPOCH + std::time::Duration::from_secs(s as u64)),
523 })
524 };
525 RateLimit {
526 primary: window(v.get("primary")),
527 secondary: window(v.get("secondary")),
528 plan: v.get("plan_type").and_then(Value::as_str).map(str::to_string),
529 reached: v.get("rate_limit_reached_type").map(|x| !x.is_null()).unwrap_or(false),
530 }
531}
532
533fn call_id(payload: &Value) -> String {
535 payload.get("call_id").or_else(|| payload.get("id")).and_then(Value::as_str).unwrap_or_default().to_string()
536}
537
538impl SessionTracker for CodexTranscript {
539 fn refresh(&mut self) -> anyhow::Result<bool> {
540 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
541 for l in &lines {
542 self.ingest(l);
543 }
544 Ok(more)
545 }
546
547 fn summary(&self) -> &SessionSummary {
548 &self.summary
549 }
550
551 fn path(&self) -> &Path {
552 self.reader.path()
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use std::io::Write;
560 use std::time::Duration;
561
562 fn rollout(dir: &Path, name: &str, written: SystemTime) -> PathBuf {
572 let p = dir.join(name);
573 std::fs::write(&p, b"x").unwrap();
574 let f = std::fs::File::options().write(true).open(&p).unwrap();
575 f.set_times(std::fs::FileTimes::new().set_accessed(written).set_modified(written)).unwrap();
576 p
577 }
578
579 const TIMEOUT: Duration = Duration::from_secs(15 * 60);
580
581 #[test]
585 fn every_live_codex_thread_is_returned_newest_first() {
586 let dir = std::env::temp_dir().join(format!("agent-top-threads-{}", std::process::id()));
587 let _ = std::fs::remove_dir_all(&dir);
588 std::fs::create_dir_all(&dir).unwrap();
589 let now = SystemTime::now();
590 let started = now - Duration::from_secs(600);
591
592 let a = rollout(&dir, "a.jsonl", now - Duration::from_secs(300));
595 let b = rollout(&dir, "b.jsonl", now - Duration::from_secs(200));
596 let c = rollout(&dir, "c.jsonl", now - Duration::from_secs(100));
597 let recent: Vec<(PathBuf, PathBuf, SystemTime)> =
598 [&a, &b, &c].iter().map(|p| ((*p).clone(), PathBuf::from("/Users/dev/code/one"), started)).collect();
599
600 let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &HashSet::new(), now, TIMEOUT);
602 assert_eq!(paths.len(), 3, "all three conversations get a row");
603 assert_eq!(paths[0], c, "newest activity first");
604 assert_eq!(attribution, Attribution::CwdHeuristic, "still a heuristic, and still labelled one");
605
606 let taken: HashSet<PathBuf> = [c.clone()].into_iter().collect();
608 let (paths, _) = attribute(Some(Path::new("/")), started, None, &recent, &taken, now, TIMEOUT);
609 assert_eq!(paths.len(), 2);
610 assert!(!paths.contains(&c));
611
612 let stale = now + TIMEOUT + Duration::from_secs(60);
616 let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &HashSet::new(), stale, TIMEOUT);
617 assert!(paths.is_empty());
618 assert_eq!(attribution, Attribution::None);
619
620 let (paths, _) = attribute(Some(Path::new("/Users/dev/code/one")), started, None, &recent, &HashSet::new(), now, TIMEOUT);
622 assert_eq!(paths.len(), 3, "a cwd match takes every conversation in that directory");
623 assert_eq!(paths[0], c);
624
625 let (paths, _) = attribute(Some(Path::new("/")), now + Duration::from_secs(3600), None, &recent, &HashSet::new(), now, TIMEOUT);
627 assert!(paths.is_empty());
628
629 let _ = std::fs::remove_dir_all(&dir);
630 }
631
632 #[test]
638 fn an_open_rollout_belongs_to_the_process_holding_it() {
639 let dir = std::env::temp_dir().join(format!("agent-top-held-{}", std::process::id()));
640 let _ = std::fs::remove_dir_all(&dir);
641 std::fs::create_dir_all(&dir).unwrap();
642 let now = SystemTime::now();
643 let started = now - Duration::from_secs(600);
644 let a = rollout(&dir, "a.jsonl", now - Duration::from_secs(200));
645 let b = rollout(&dir, "b.jsonl", now - Duration::from_secs(100));
646 let recent: Vec<(PathBuf, PathBuf, SystemTime)> =
647 [&a, &b].iter().map(|p| ((*p).clone(), PathBuf::from("/Users/dev/code/one"), started)).collect();
648
649 let held = vec![a.clone(), b.clone()];
653 let (paths, attribution) = attribute(Some(Path::new("/")), now, Some(&held), &recent, &HashSet::new(), now, TIMEOUT);
654 assert_eq!(paths, vec![b.clone(), a.clone()], "held rollouts, newest written first");
655 assert_eq!(attribution, Attribution::OpenFile);
656
657 let taken: HashSet<PathBuf> = held.iter().cloned().collect();
660 let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &taken, now, TIMEOUT);
661 assert!(paths.is_empty());
662 assert_eq!(attribution, Attribution::None);
663
664 let _ = std::fs::remove_dir_all(&dir);
665 }
666
667 #[test]
668 fn the_rollout_id_follows_the_timestamp() {
669 assert_eq!(
670 rollout_id(Path::new("/x/2026/05/14/rollout-2026-05-14T21-37-50-01000000-0000-7000-0000-000000000000.jsonl")),
671 "01000000-0000-7000-0000-000000000000"
672 );
673 assert_eq!(rollout_id(Path::new("/x/odd.jsonl")), "odd");
674 }
675
676 #[test]
677 fn finds_a_fresh_rollout_under_stale_directories() {
678 let root = std::env::temp_dir().join(format!("agent-top-rollouts-{}", std::process::id()));
679 let day = root.join("2026").join("09").join("04");
680 std::fs::create_dir_all(&day).unwrap();
681 let fresh = day.join("rollout-fresh.jsonl");
682 let stale = day.join("rollout-stale.jsonl");
683 std::fs::write(&fresh, "{}\n").unwrap();
684 std::fs::write(&stale, "{}\n").unwrap();
685 let now = SystemTime::now();
686 let long_ago = now - Duration::from_secs(40 * 86_400);
687 std::fs::File::open(&stale).unwrap().set_modified(long_ago).unwrap();
688 for dir in [&root, &root.join("2026"), &root.join("2026").join("09"), &day] {
689 std::fs::File::open(dir).unwrap().set_modified(long_ago).unwrap();
690 }
691 let found = rollouts_under(&root, now - Duration::from_secs(1800));
692 assert_eq!(found, vec![fresh], "the fresh file is found through directories nobody has touched in weeks");
693 std::fs::remove_dir_all(&root).unwrap();
694 }
695
696 #[test]
697 fn reads_the_latest_rate_limit_snapshot() {
698 let dir = std::env::temp_dir().join(format!("agent-top-codex-rl-{}", std::process::id()));
699 std::fs::create_dir_all(&dir).unwrap();
700 let path = dir.join("rollout.jsonl");
701 let mut f = std::fs::File::create(&path).unwrap();
702 writeln!(f, r#"{{"timestamp":"2026-06-16T20:45:04.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":10,"output_tokens":1,"total_tokens":11}}}},"rate_limits":{{"limit_id":"codex","primary":{{"used_percent":1.0,"window_minutes":300,"resets_at":1781660699}},"secondary":{{"used_percent":27.0,"window_minutes":10080,"resets_at":1782080576}},"plan_type":"plus","rate_limit_reached_type":null}}}}}}"#).unwrap();
703 writeln!(f, r#"{{"timestamp":"2026-06-16T20:50:00.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":20,"output_tokens":2,"total_tokens":22}}}},"rate_limits":{{"limit_id":"codex","primary":{{"used_percent":42.0,"window_minutes":300,"resets_at":1781660999}},"secondary":{{"used_percent":28.0,"window_minutes":10080,"resets_at":1782080576}},"plan_type":"plus","rate_limit_reached_type":"primary"}}}}}}"#).unwrap();
705 let mut t = CodexTranscript::new(&path);
706 t.refresh().unwrap();
707 let rl = t.summary().rate_limit.as_ref().expect("rate limit parsed");
708 assert_eq!(rl.plan.as_deref(), Some("plus"));
709 assert!(rl.reached, "the latest snapshot reports the primary window hit");
710 let p = rl.primary.expect("primary window");
711 assert_eq!(p.used_percent, 42.0, "the latest value, not the first");
712 assert_eq!(p.window_minutes, 300);
713 assert_eq!(rl.secondary.unwrap().used_percent, 28.0);
714 assert_eq!(rl.tightest().map(|w| w.used_percent), Some(42.0));
715 let _ = std::fs::remove_dir_all(&dir);
716 }
717
718 #[test]
719 fn counts_mcp_calls_per_server_from_the_end_event() {
720 let dir = std::env::temp_dir().join(format!("agent-top-codex-mcp-{}", std::process::id()));
721 std::fs::create_dir_all(&dir).unwrap();
722 let path = dir.join("rollout.jsonl");
723 let mut f = std::fs::File::create(&path).unwrap();
724 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();
728 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"c1"}}}}"#).unwrap();
729 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();
730 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();
731 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();
732 let mut t = CodexTranscript::new(&path);
733 t.refresh().unwrap();
734 let s = t.summary();
735 assert_eq!(s.mcp.len(), 2);
736 let apps = &s.mcp["codex_apps"];
737 assert_eq!((apps.calls, apps.errors), (2, 1));
738 assert_eq!(apps.last_call, parse_rfc3339_utc("2026-05-27T09:00:05.000Z"));
739 assert_eq!(s.mcp["node_repl"].calls, 1);
740 assert_eq!(s.tool_calls, 1, "mcp_tool_call_end must not double-count tool calls");
743 let _ = std::fs::remove_dir_all(&dir);
744 }
745
746 #[test]
747 fn sizes_context_per_tool_from_last_token_usage_and_skips_the_repeated_snapshot() {
748 let dir = std::env::temp_dir().join(format!("agent-top-codex-context-{}", std::process::id()));
749 std::fs::create_dir_all(&dir).unwrap();
750 let path = dir.join("rollout.jsonl");
751 let mut f = std::fs::File::create(&path).unwrap();
752 let count = |input: u64, cached: u64, out: u64| {
753 format!(
754 r#"{{"timestamp":"2026-05-27T09:00:00.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":{input},"cached_input_tokens":{cached},"output_tokens":{out}}},"last_token_usage":{{"input_tokens":{input},"cached_input_tokens":{cached},"output_tokens":{out}}}}}}}}}"#
755 )
756 };
757 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:00.000Z","type":"session_meta","payload":{{"id":"s","cwd":"/tmp"}}}}"#).unwrap();
758 writeln!(f, "{}", count(10_000, 0, 100)).unwrap();
759 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:01.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"c1","name":"exec_command"}}}}"#).unwrap();
760 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:01.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"c2","name":"github_fetch_file"}}}}"#).unwrap();
761 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"c1"}}}}"#).unwrap();
762 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"c2"}}}}"#).unwrap();
763 writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.100Z","type":"event_msg","payload":{{"type":"mcp_tool_call_end","call_id":"c2","invocation":{{"server":"codex_apps","tool":"github_fetch_file"}},"result":{{"Ok":{{}}}}}}}}"#).unwrap();
765 writeln!(f, "{}", count(13_100, 12_000, 40)).unwrap();
767 writeln!(f, r#"{{"timestamp":"2026-05-27T09:01:00.000Z","type":"event_msg","payload":{{"type":"task_started"}}}}"#).unwrap();
769 writeln!(f, "{}", count(13_100, 12_000, 40)).unwrap();
770 let mut t = CodexTranscript::new(&path).with_prices(pricing::builtin_table());
771 t.refresh().unwrap();
772 let c: HashMap<String, crate::model::ContextSource> =
773 t.summary().context.sources().into_iter().map(|c| (c.name.clone(), c)).collect();
774 assert_eq!(c["exec_command"].tokens, 1_500);
775 assert_eq!((c["codex_apps"].tokens, c["codex_apps"].origin), (1_500, ContextOrigin::Mcp));
776 assert!(!c.contains_key("github_fetch_file"), "re-filed under its server");
777 assert_eq!(c["other"].tokens, 10_100, "the repeated snapshot added nothing");
778 assert_eq!(c["other"].cost_usd, 0.0, "no price for the model: tokens only");
779 let _ = std::fs::remove_dir_all(&dir);
780 }
781
782 #[test]
783 fn reads_cumulative_usage_and_state() {
784 let dir = std::env::temp_dir().join(format!("agent-top-codex-{}", std::process::id()));
785 std::fs::create_dir_all(&dir).unwrap();
786 let path = dir.join("rollout.jsonl");
787 let mut f = std::fs::File::create(&path).unwrap();
788 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();
789 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:21.000Z","type":"turn_context","payload":{{"model":"gpt-5-codex"}}}}"#).unwrap();
790 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:22.000Z","type":"event_msg","payload":{{"type":"task_started"}}}}"#).unwrap();
791 writeln!(
792 f,
793 r#"{{"timestamp":"2026-08-28T08:53:23.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"call_1","name":"shell"}}}}"#
794 )
795 .unwrap();
796 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();
797 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.000Z","type":"event_msg","payload":{{"type":"token_count","info":null}}}}"#)
798 .unwrap();
799 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.500Z","type":"response_item","payload":{{"type":"web_search_call","status":"completed"}}}}"#).unwrap();
800 let mut t = CodexTranscript::new(&path).with_prices(pricing::builtin_table());
801 t.refresh().unwrap();
802 let s = t.summary();
803 assert_eq!(s.session_id.as_deref(), Some("01a0"));
804 assert_eq!(s.model.as_deref(), Some("gpt-5-codex"));
805 assert_eq!(s.usage.input, 14778 - 12672);
806 assert_eq!(s.usage.cache_read, 12672);
807 assert_eq!(s.usage.total(), 15019);
808 assert_eq!(s.unpriced_tokens, 0, "priced now that OpenAI's rows are in the table");
811 assert!((s.cost_usd - (2106.0 * 1.25 + 12672.0 * 0.125 + 241.0 * 10.0) / 1_000_000.0).abs() < 1e-9, "{}", s.cost_usd);
812 assert_eq!(s.tool_calls, 1);
813 assert_eq!(s.activity, Activity::Working);
814 assert_eq!(read_meta(&path).unwrap().0, PathBuf::from("/tmp/p"));
815 assert_eq!(s.web_searches, 1);
816 let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
817 assert_eq!(tools.len(), 1);
818 assert_eq!(tools[0].name, "shell");
819 assert!(tools[0].is_open(), "no output item yet");
820 let turns: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Turn).collect();
821 assert_eq!(turns.len(), 1);
822 assert!(turns[0].is_open(), "task_started with no task_complete");
823 let _ = std::fs::remove_dir_all(&dir);
824 }
825
826 #[test]
827 fn pairs_calls_with_their_outputs() {
828 let dir = std::env::temp_dir().join(format!("agent-top-codex-spans-{}", std::process::id()));
829 std::fs::create_dir_all(&dir).unwrap();
830 let path = dir.join("rollout.jsonl");
831 let mut f = std::fs::File::create(&path).unwrap();
832 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();
833 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();
834 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();
835 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();
836 writeln!(
838 f,
839 r#"{{"timestamp":"2026-08-28T08:53:27.600Z","type":"response_item","payload":{{"type":"message","role":"assistant"}}}}"#
840 )
841 .unwrap();
842 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:27.700Z","type":"event_msg","payload":{{"type":"task_complete"}}}}"#).unwrap();
843 let mut t = CodexTranscript::new(&path);
844 t.refresh().unwrap();
845 let all = t.summary().spans.to_vec();
846 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
847 assert_eq!(spans.len(), 2);
848 assert_eq!(spans[0].name, "exec_command");
849 assert_eq!(spans[0].duration_ms, Some(1_000));
850 assert_eq!(spans[1].name, "apply_patch");
851 assert_eq!(spans[1].duration_ms, Some(3_000));
852 assert_eq!(t.summary().tool_calls, 2);
853 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
856 assert_eq!(inf.len(), 1);
857 assert_eq!(inf[0].duration_ms, Some(3_600));
858 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no task_started in this file");
859 let _ = std::fs::remove_dir_all(&dir);
860 }
861}