Skip to main content

dynamo_bench/coding/codex/
parser.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Text-free reduction of one Codex rollout file into the events of the thread that owns it.
5//!
6//! Every rollout line carries a contiguous `ordinal`. A forked child copies part of its parent's
7//! history into ordinals `1..subagent_history_start_ordinal`; those lines describe the parent and
8//! are skipped. Each completed model response writes one `token_usage_record`, so requests are
9//! reconstructed from those records and the response items between them.
10
11use 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/// Usage of one completed response, as recorded in `token_usage_record`.
19#[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/// One completed model response.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CodexRequest {
32    pub response_id: String,
33    pub model: String,
34    /// The latest input line before the response's first output, or the compaction start.
35    pub start_ms: i64,
36    pub end_ms: i64,
37    pub usage: Usage,
38    pub compaction: bool,
39}
40
41/// One tool call emitted by a response.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct CodexTool {
44    pub call_id: String,
45    pub name: String,
46    /// Index into [`RolloutThread::requests`] of the response that emitted the call.
47    pub source_request: Option<usize>,
48    pub started_at_ms: i64,
49    pub ended_at_ms: Option<i64>,
50}
51
52/// A spawn, follow-up, or message this thread queued for another thread.
53#[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/// A child's completion, recorded in the parent's rollout when the child's turn finishes.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ChildCompletion {
63    pub child_thread_id: String,
64    pub queued_at_ms: i64,
65}
66
67/// Responses whose usage Codex never recorded.
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
69pub struct UnrecordedResponses {
70    /// Cut off by a queued inter-agent message.
71    pub preempted: usize,
72    /// Ended by an interrupt, an error, or the end of the turn.
73    pub aborted: usize,
74    /// Followed by input without a usage record, as in turns written before the rollout
75    /// recorded usage.
76    pub untracked: usize,
77}
78
79/// Text-free events of one Codex thread, from its own rollout lines only.
80#[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/// Why a rollout file contributes no thread.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum SkipReason {
98    /// The file predates per-line ordinals, so inherited fork history cannot be separated.
99    NoOrdinals,
100    /// The file has no usage records, so no request can be replayed.
101    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/// Union of the payload fields the reduction reads. Serde skips every other field without
116/// allocating, so message and tool text is never materialized.
117#[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
163/// Reduces one rollout file to the events of the thread that owns it.
164pub 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            // A thread that is still running can end in a partially written line, which may
193            // stop inside a multi-byte character.
194            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    /// Start of the response whose outputs have been written but whose usage has not.
219    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                // The compaction's own usage record is the line just before its checkpoint.
266                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            // Codex also counts tokens when a response is cut off, before the line saying why.
285            ("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                // A queued message cuts off the in-flight response without a usage record.
292                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    /// Ends a response whose outputs were written but whose usage never was.
336    ///
337    /// Tool outputs follow the usage record, so input written while a response is open means the
338    /// response ended without one. Its tool calls stay in the thread without a source request.
339    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        // The cut-off response's start does not leak into the next one.
462        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        // Cut a write off inside the three-byte encoding of an em dash.
514        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}