1use crate::coding::common::parse_utc_timestamp_ms;
12use anyhow::{Context, Result};
13use serde::Deserialize;
14use std::fs::File;
15use std::io::{BufRead, BufReader};
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
20pub struct Usage {
21 #[serde(default)]
22 pub input_tokens: u64,
23 #[serde(default)]
24 pub cached_input_tokens: u64,
25 #[serde(default)]
26 pub output_tokens: u64,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CodexRequest {
32 pub response_id: String,
33 pub model: String,
34 pub start_ms: i64,
36 pub end_ms: i64,
37 pub usage: Usage,
38 pub compaction: bool,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CodexTool {
44 pub call_id: String,
45 pub name: String,
46 pub source_request: Option<usize>,
48 pub started_at_ms: i64,
49 pub ended_at_ms: Option<i64>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct AgentSend {
55 pub call_id: String,
56 pub recipient_thread_id: String,
57 pub queued_at_ms: i64,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ChildCompletion {
63 pub child_thread_id: String,
64 pub queued_at_ms: i64,
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct UnrecordedResponses {
70 pub preempted: usize,
72 pub aborted: usize,
74 pub untracked: usize,
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
81pub struct RolloutThread {
82 pub path: PathBuf,
83 pub thread_id: String,
84 pub parent_thread_id: Option<String>,
85 pub forked_from_id: Option<String>,
86 pub cwd: String,
87 pub requests: Vec<CodexRequest>,
88 pub tools: Vec<CodexTool>,
89 pub sends: Vec<AgentSend>,
90 pub child_completions: Vec<ChildCompletion>,
91 pub unrecorded: UnrecordedResponses,
92 pub unmatched_compactions: usize,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum SkipReason {
98 NoOrdinals,
100 NoUsageRecords,
102}
103
104#[derive(Debug, Deserialize)]
105struct Line {
106 #[serde(default)]
107 ordinal: Option<u64>,
108 timestamp: String,
109 #[serde(rename = "type")]
110 kind: String,
111 #[serde(default)]
112 payload: Payload,
113}
114
115#[derive(Debug, Default, Deserialize)]
118struct Payload {
119 #[serde(rename = "type", default)]
120 kind: Option<String>,
121 #[serde(default)]
122 id: Option<String>,
123 #[serde(default)]
124 parent_thread_id: Option<String>,
125 #[serde(default)]
126 forked_from_id: Option<String>,
127 #[serde(default)]
128 subagent_history_start_ordinal: Option<u64>,
129 #[serde(default)]
130 cwd: Option<String>,
131 #[serde(default)]
132 model: Option<String>,
133 #[serde(default)]
134 role: Option<String>,
135 #[serde(default)]
136 name: Option<String>,
137 #[serde(default)]
138 call_id: Option<String>,
139 #[serde(default)]
140 response_id: Option<String>,
141 #[serde(default)]
142 usage: Option<Usage>,
143 #[serde(default)]
144 compaction_response_id: Option<String>,
145 #[serde(default)]
146 started_at_ms: Option<i64>,
147 #[serde(default)]
148 item: Option<Item>,
149}
150
151#[derive(Debug, Default, Deserialize)]
152struct Item {
153 #[serde(rename = "type", default)]
154 kind: Option<String>,
155 #[serde(default)]
156 id: Option<String>,
157 #[serde(rename = "kind", default)]
158 activity: Option<String>,
159 #[serde(default)]
160 agent_thread_id: Option<String>,
161}
162
163pub fn parse_rollout_file(path: &Path) -> Result<std::result::Result<RolloutThread, SkipReason>> {
165 let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
166 let mut reducer = Reducer {
167 thread: RolloutThread {
168 path: path.to_path_buf(),
169 ..Default::default()
170 },
171 ..Default::default()
172 };
173 let mut reader = BufReader::new(file);
174 let mut line = Vec::new();
175 let mut line_number = 0;
176 loop {
177 line.clear();
178 if reader
179 .read_until(b'\n', &mut line)
180 .with_context(|| format!("failed to read {}", path.display()))?
181 == 0
182 {
183 break;
184 }
185 line_number += 1;
186 let complete = line.ends_with(b"\n");
187 if line.trim_ascii().is_empty() {
188 continue;
189 }
190 let parsed: Line = match serde_json::from_slice(&line) {
191 Ok(parsed) => parsed,
192 Err(error) if error.is_eof() && !complete => break,
195 Err(error) => {
196 return Err(error).with_context(|| {
197 format!("invalid rollout line {}:{line_number}", path.display())
198 });
199 }
200 };
201 let Some(ordinal) = parsed.ordinal else {
202 return Ok(Err(SkipReason::NoOrdinals));
203 };
204 reducer.apply(ordinal, parsed)?;
205 }
206 if reducer.thread.requests.is_empty() {
207 return Ok(Err(SkipReason::NoUsageRecords));
208 }
209 Ok(Ok(reducer.thread))
210}
211
212#[derive(Debug, Default)]
213struct Reducer {
214 thread: RolloutThread,
215 own_history_start: u64,
216 model: String,
217 last_input_ms: Option<i64>,
218 response_start_ms: Option<i64>,
220 pending_tools: Vec<usize>,
221 previous_line_was_usage: bool,
222}
223
224impl Reducer {
225 fn apply(&mut self, ordinal: u64, line: Line) -> Result<()> {
226 let payload = line.payload;
227 if ordinal == 0 {
228 if line.kind == "session_meta" {
229 self.thread.thread_id = payload.id.unwrap_or_default();
230 self.thread.parent_thread_id = payload.parent_thread_id;
231 self.thread.forked_from_id = payload.forked_from_id;
232 self.thread.cwd = payload.cwd.unwrap_or_default();
233 self.own_history_start = payload.subagent_history_start_ordinal.unwrap_or(0);
234 }
235 return Ok(());
236 }
237 if ordinal < self.own_history_start {
238 return Ok(());
239 }
240 let at_ms = parse_utc_timestamp_ms(&line.timestamp)?;
241 let previous_line_was_usage = std::mem::take(&mut self.previous_line_was_usage);
242 match (line.kind.as_str(), payload.kind.as_deref()) {
243 ("token_usage_record", _) => {
244 self.previous_line_was_usage = true;
245 let start_ms = self
246 .response_start_ms
247 .take()
248 .or(self.last_input_ms)
249 .unwrap_or(at_ms)
250 .min(at_ms);
251 let index = self.thread.requests.len();
252 for tool in self.pending_tools.drain(..) {
253 self.thread.tools[tool].source_request = Some(index);
254 }
255 self.thread.requests.push(CodexRequest {
256 response_id: payload.response_id.unwrap_or_default(),
257 model: self.model.clone(),
258 start_ms,
259 end_ms: at_ms,
260 usage: payload.usage.unwrap_or_default(),
261 compaction: false,
262 });
263 }
264 ("compacted", _) => {
265 match self.thread.requests.last_mut() {
267 Some(request)
268 if previous_line_was_usage
269 && payload.compaction_response_id.as_deref()
270 == Some(request.response_id.as_str()) =>
271 {
272 request.compaction = true;
273 }
274 _ => self.thread.unmatched_compactions += 1,
275 }
276 }
277 ("turn_context", _) => {
278 if let Some(model) = payload.model {
279 self.model = model;
280 }
281 self.last_input_ms = Some(at_ms);
282 }
283 ("world_state", _) => self.last_input_ms = Some(at_ms),
284 ("event_msg", Some("token_count")) => self.last_input_ms = Some(at_ms),
286 ("event_msg", Some("turn_aborted" | "task_complete")) => {
287 self.close_unrecorded_response(|unrecorded| &mut unrecorded.aborted);
288 }
289 ("event_msg", Some("item_completed")) => self.apply_item(payload, at_ms),
290 ("response_item", Some("agent_message")) => {
291 self.close_unrecorded_response(|unrecorded| &mut unrecorded.preempted);
293 self.last_input_ms = Some(at_ms);
294 }
295 ("response_item", Some("message")) if payload.role.as_deref() != Some("assistant") => {
296 self.close_unrecorded_response(|unrecorded| &mut unrecorded.untracked);
297 self.last_input_ms = Some(at_ms);
298 }
299 ("response_item", Some("function_call_output" | "custom_tool_call_output")) => {
300 self.close_unrecorded_response(|unrecorded| &mut unrecorded.untracked);
301 self.last_input_ms = Some(at_ms);
302 if let Some(call_id) = payload.call_id
303 && let Some(tool) = self
304 .thread
305 .tools
306 .iter_mut()
307 .rev()
308 .find(|tool| tool.call_id == call_id && tool.ended_at_ms.is_none())
309 {
310 tool.ended_at_ms = Some(at_ms);
311 }
312 }
313 (
314 "response_item",
315 Some("reasoning" | "message" | "function_call" | "custom_tool_call"),
316 ) => {
317 self.response_start_ms
318 .get_or_insert(self.last_input_ms.unwrap_or(at_ms));
319 if let Some(call_id) = payload.call_id {
320 self.pending_tools.push(self.thread.tools.len());
321 self.thread.tools.push(CodexTool {
322 call_id,
323 name: payload.name.unwrap_or_default(),
324 source_request: None,
325 started_at_ms: at_ms,
326 ended_at_ms: None,
327 });
328 }
329 }
330 _ => {}
331 }
332 Ok(())
333 }
334
335 fn close_unrecorded_response(
340 &mut self,
341 counter: impl FnOnce(&mut UnrecordedResponses) -> &mut usize,
342 ) {
343 if self.response_start_ms.take().is_some() {
344 *counter(&mut self.thread.unrecorded) += 1;
345 self.pending_tools.clear();
346 }
347 }
348
349 fn apply_item(&mut self, payload: Payload, at_ms: i64) {
350 let Some(item) = payload.item else {
351 return;
352 };
353 match item.kind.as_deref() {
354 Some("ContextCompaction") => {
355 if let Some(request) = self.thread.requests.last_mut()
356 && request.compaction
357 && let Some(started_at_ms) = payload.started_at_ms
358 && started_at_ms <= request.end_ms
359 {
360 request.start_ms = started_at_ms;
361 }
362 }
363 Some("SubAgentActivity") => {
364 let (Some(id), Some(agent_thread_id)) = (item.id, item.agent_thread_id) else {
365 return;
366 };
367 match item.activity.as_deref() {
368 Some("started" | "interacted") => self.thread.sends.push(AgentSend {
369 call_id: id,
370 recipient_thread_id: agent_thread_id,
371 queued_at_ms: at_ms,
372 }),
373 Some("completed") => self.thread.child_completions.push(ChildCompletion {
374 child_thread_id: agent_thread_id,
375 queued_at_ms: at_ms,
376 }),
377 _ => {}
378 }
379 }
380 _ => {}
381 }
382 }
383}
384
385#[cfg(test)]
386pub(crate) mod tests {
387 use super::{SkipReason, parse_rollout_file};
388 use crate::coding::common::parse_utc_timestamp_ms;
389 use serde_json::{Value, json};
390 use std::io::Write;
391 use tempfile::NamedTempFile;
392
393 pub(crate) fn rollout(lines: &[Value]) -> NamedTempFile {
394 let mut file = NamedTempFile::with_suffix(".jsonl").unwrap();
395 for (ordinal, line) in lines.iter().enumerate() {
396 let mut line = line.clone();
397 line["ordinal"] = json!(ordinal);
398 writeln!(file, "{line}").unwrap();
399 }
400 file
401 }
402
403 pub(crate) fn line(second: u32, kind: &str, payload: Value) -> Value {
404 json!({"timestamp": format!("2026-09-30T10:{:02}:{:02}.000Z", second / 60, second % 60), "type": kind, "payload": payload})
405 }
406
407 fn usage(second: u32, response_id: &str, input: u64, cached: u64, output: u64) -> Value {
408 line(
409 second,
410 "token_usage_record",
411 json!({"response_id": response_id, "usage": {"input_tokens": input, "cached_input_tokens": cached, "output_tokens": output}}),
412 )
413 }
414
415 #[test]
416 fn forked_child_skips_inherited_history_and_counts_preemption() {
417 let file = rollout(&[
418 line(
419 0,
420 "session_meta",
421 json!({"id": "child", "parent_thread_id": "parent", "forked_from_id": "parent", "subagent_history_start_ordinal": 4, "cwd": "/repo"}),
422 ),
423 line(0, "session_meta", json!({"id": "parent"})),
424 line(0, "token_usage_record", json!({"response_id": "inherited"})),
425 line(
426 0,
427 "compacted",
428 json!({"compaction_response_id": "inherited"}),
429 ),
430 line(1, "turn_context", json!({"model": "gpt"})),
431 line(2, "response_item", json!({"type": "reasoning"})),
432 line(
433 3,
434 "response_item",
435 json!({"type": "agent_message", "author": "/root"}),
436 ),
437 line(
438 5,
439 "response_item",
440 json!({"type": "function_call", "call_id": "call-1", "name": "exec"}),
441 ),
442 usage(6, "resp-1", 100, 90, 10),
443 line(
444 7,
445 "response_item",
446 json!({"type": "function_call_output", "call_id": "call-1"}),
447 ),
448 ]);
449
450 let thread = parse_rollout_file(file.path()).unwrap().unwrap();
451 assert_eq!(thread.thread_id, "child");
452 assert_eq!(thread.forked_from_id.as_deref(), Some("parent"));
453 assert_eq!(thread.unrecorded.preempted, 1);
454 assert_eq!(thread.unmatched_compactions, 0);
455 assert_eq!(thread.requests.len(), 1);
456 let request = &thread.requests[0];
457 assert_eq!(
458 (request.response_id.as_str(), request.model.as_str()),
459 ("resp-1", "gpt")
460 );
461 assert_eq!(request.end_ms - request.start_ms, 3_000);
463 assert_eq!(thread.tools[0].source_request, Some(0));
464 assert_eq!(thread.tools[0].ended_at_ms, Some(request.end_ms + 1_000));
465 }
466
467 #[test]
468 fn compaction_is_the_usage_record_before_its_checkpoint() {
469 let started_at_ms = parse_utc_timestamp_ms("2026-09-30T10:00:02.000Z").unwrap();
470 let file = rollout(&[
471 line(0, "session_meta", json!({"id": "root"})),
472 line(
473 1,
474 "response_item",
475 json!({"type": "message", "role": "user"}),
476 ),
477 usage(5, "resp-compact", 200, 190, 20),
478 line(
479 5,
480 "compacted",
481 json!({"compaction_response_id": "resp-compact"}),
482 ),
483 line(
484 5,
485 "event_msg",
486 json!({"type": "item_completed", "started_at_ms": started_at_ms, "item": {"type": "ContextCompaction", "id": "c"}}),
487 ),
488 line(
489 6,
490 "event_msg",
491 json!({"type": "item_completed", "item": {"type": "SubAgentActivity", "id": "call-2", "kind": "interacted", "agent_thread_id": "child"}}),
492 ),
493 ]);
494
495 let thread = parse_rollout_file(file.path()).unwrap().unwrap();
496 assert!(thread.requests[0].compaction);
497 assert_eq!(thread.requests[0].start_ms, started_at_ms);
498 assert_eq!(thread.sends[0].recipient_thread_id, "child");
499 }
500
501 #[test]
502 fn partially_written_final_line_is_ignored() {
503 let mut file = rollout(&[
504 line(0, "session_meta", json!({"id": "root"})),
505 line(
506 1,
507 "response_item",
508 json!({"type": "message", "role": "user"}),
509 ),
510 line(2, "response_item", json!({"type": "reasoning"})),
511 usage(3, "resp-1", 100, 0, 10),
512 ]);
513 let partial = format!(
515 r#"{{"ordinal":4,"timestamp":"2026-09-30T10:00:04.000Z","type":"response_item","payload":{{"type":"message","role":"user","content":"a{}"#,
516 '\u{2014}'
517 );
518 file.write_all(&partial.as_bytes()[..partial.len() - 1])
519 .unwrap();
520 assert_eq!(
521 parse_rollout_file(file.path())
522 .unwrap()
523 .unwrap()
524 .requests
525 .len(),
526 1
527 );
528
529 let mut corrupt = rollout(&[line(0, "session_meta", json!({"id": "root"}))]);
530 writeln!(corrupt, "{{\"ordinal\":1,").unwrap();
531 writeln!(corrupt, "{}", usage(3, "resp-1", 100, 0, 10)).unwrap();
532 assert!(parse_rollout_file(corrupt.path()).is_err());
533 }
534
535 #[test]
536 fn outputs_without_usage_do_not_stretch_the_next_request() {
537 let file = rollout(&[
538 line(0, "session_meta", json!({"id": "root"})),
539 line(
540 1,
541 "response_item",
542 json!({"type": "message", "role": "user"}),
543 ),
544 line(
545 2,
546 "response_item",
547 json!({"type": "function_call", "call_id": "legacy", "name": "exec"}),
548 ),
549 line(
550 3,
551 "response_item",
552 json!({"type": "function_call_output", "call_id": "legacy"}),
553 ),
554 line(
555 10,
556 "response_item",
557 json!({"type": "message", "role": "user"}),
558 ),
559 line(11, "response_item", json!({"type": "reasoning"})),
560 usage(12, "resp-1", 100, 0, 10),
561 ]);
562
563 let thread = parse_rollout_file(file.path()).unwrap().unwrap();
564 assert_eq!(thread.unrecorded.untracked, 1);
565 assert_eq!(
566 thread.requests[0].end_ms - thread.requests[0].start_ms,
567 2_000
568 );
569 assert_eq!(thread.tools[0].source_request, None);
570 }
571
572 #[test]
573 fn files_without_usage_or_ordinals_are_skipped() {
574 let empty = rollout(&[line(0, "session_meta", json!({"id": "root"}))]);
575 assert_eq!(
576 parse_rollout_file(empty.path()).unwrap(),
577 Err(SkipReason::NoUsageRecords)
578 );
579
580 let mut legacy = NamedTempFile::with_suffix(".jsonl").unwrap();
581 writeln!(legacy, "{}", line(0, "session_meta", json!({"id": "root"}))).unwrap();
582 assert_eq!(
583 parse_rollout_file(legacy.path()).unwrap(),
584 Err(SkipReason::NoOrdinals)
585 );
586 }
587}