Skip to main content

dynamo_bench/coding/codex/
export.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Codex request-trace export: request ordering, cross-thread causality, and replay rows.
5
6use crate::coding::codex::parser::{CodexTool, RolloutThread, SkipReason, parse_rollout_file};
7use crate::coding::common::anonymized_session_id;
8use crate::coding::replay::{
9    AgentContext, HARNESS_EVENT_SOURCE, PrefixPool, REQUEST_TRACE_SCHEMA, ReplayBase,
10    ReplayDependency, ReplayFields, RequestEndEvent, RequestFields, ToolEvent, ToolFields,
11    TraceLine, prefix_pool_key, synthetic_stream_seed, usage_shaped_tokens,
12};
13use anyhow::{Context, Result, bail};
14use dynamo_data_gen::{AgenticDependencyRelation, AgenticDependencyTrigger};
15use rustc_hash::FxHashMap;
16use serde::Serialize;
17use std::fs::File;
18use std::io::{BufWriter, Write};
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicUsize, Ordering};
21
22#[derive(Debug, Clone, Copy)]
23pub struct ExportConfig {
24    pub block_size: usize,
25    pub preserve_session_ids: bool,
26}
27
28/// What the export kept, dropped, and linked, for judging how faithful a replay can be.
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct ExportReport {
31    pub files: usize,
32    pub skipped_without_ordinals: usize,
33    pub skipped_without_usage: usize,
34    pub duplicate_threads: usize,
35    pub threads: usize,
36    pub requests: usize,
37    pub compactions: usize,
38    pub tools: usize,
39    pub preempted_responses: usize,
40    pub aborted_responses: usize,
41    pub untracked_responses: usize,
42    pub unmatched_compactions: usize,
43    pub task_edges: usize,
44    pub wait_edges: usize,
45    pub reattributed_calls: usize,
46    pub unlinked_waits: usize,
47    pub undelivered_tasks: usize,
48    pub unresolved_agents: usize,
49    pub cache_prefix_blocks: usize,
50    pub forked_prefix_blocks: usize,
51    pub pooled_prefix_blocks: usize,
52}
53
54impl ExportReport {
55    pub fn render(&self) -> String {
56        format!(
57            "Files: rollouts={} skipped_without_ordinals={} skipped_without_usage={} duplicate_threads={}\n\
58             Requests: threads={} requests={} compactions={} tools={} cache_prefix_blocks={} forked_prefix_blocks={} pooled_prefix_blocks={}\n\
59             Causality: task_edges={} wait_edges={} reattributed_calls={} unlinked_waits={} undelivered_tasks={} unresolved_agents={}\n\
60             Limitations: synthetic_kv_hashes={} dropped_preempted_responses={} dropped_aborted_responses={} dropped_untracked_responses={} unmatched_compactions={}",
61            self.files,
62            self.skipped_without_ordinals,
63            self.skipped_without_usage,
64            self.duplicate_threads,
65            self.threads,
66            self.requests,
67            self.compactions,
68            self.tools,
69            self.cache_prefix_blocks,
70            self.forked_prefix_blocks,
71            self.pooled_prefix_blocks,
72            self.task_edges,
73            self.wait_edges,
74            self.reattributed_calls,
75            self.unlinked_waits,
76            self.undelivered_tasks,
77            self.unresolved_agents,
78            self.requests,
79            self.preempted_responses,
80            self.aborted_responses,
81            self.untracked_responses,
82            self.unmatched_compactions,
83        )
84    }
85}
86
87/// Parses rollout files on `workers` threads, keeping one copy of each Codex thread.
88///
89/// Codex moves rollouts into `archived_sessions` while it runs, so one thread can be discovered
90/// twice; the copy with more recorded requests wins.
91pub fn load_threads(
92    paths: &[PathBuf],
93    workers: usize,
94    report: &mut ExportReport,
95) -> Result<Vec<RolloutThread>> {
96    let next = AtomicUsize::new(0);
97    let mut parsed = std::thread::scope(|scope| {
98        let handles = (0..workers.max(1))
99            .map(|_| {
100                scope.spawn(|| {
101                    let mut local = Vec::new();
102                    while let Some(path) = paths.get(next.fetch_add(1, Ordering::Relaxed)) {
103                        local.push((path, parse_rollout_file(path)));
104                    }
105                    local
106                })
107            })
108            .collect::<Vec<_>>();
109        handles
110            .into_iter()
111            .flat_map(|handle| handle.join().expect("rollout parser panicked"))
112            .collect::<Vec<_>>()
113    });
114    parsed.sort_by(|left, right| left.0.cmp(right.0));
115
116    report.files = paths.len();
117    let mut by_thread: FxHashMap<String, RolloutThread> = FxHashMap::default();
118    for (_, outcome) in parsed {
119        let thread = match outcome? {
120            Ok(thread) => thread,
121            Err(SkipReason::NoOrdinals) => {
122                report.skipped_without_ordinals += 1;
123                continue;
124            }
125            Err(SkipReason::NoUsageRecords) => {
126                report.skipped_without_usage += 1;
127                continue;
128            }
129        };
130        if let Some(existing) = by_thread.get(&thread.thread_id) {
131            report.duplicate_threads += 1;
132            if existing.requests.len() >= thread.requests.len() {
133                continue;
134            }
135        }
136        by_thread.insert(thread.thread_id.clone(), thread);
137    }
138    let mut threads = by_thread.into_values().collect::<Vec<_>>();
139    threads.sort_by(|left, right| left.path.cmp(&right.path));
140    Ok(threads)
141}
142
143/// A message queued for a thread, with the request whose completion queued it.
144#[derive(Debug, Clone, Copy)]
145struct Queued {
146    queued_at_ms: i64,
147    sender: (usize, usize),
148    kind: QueuedKind,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152enum QueuedKind {
153    /// `spawn_agent`: the recipient's first turn.
154    Spawn,
155    /// `followup_task`: a new task that starts a turn.
156    Task,
157    /// `send_message` or a child's final answer: only queued.
158    Message,
159}
160
161/// An edge from a request to the request it waited on.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163struct Edge {
164    sender: (usize, usize),
165    relation: AgenticDependencyRelation,
166}
167
168/// Cross-thread edges by thread and request, and the request that spawned each thread.
169struct Links {
170    edges: Vec<Vec<Vec<Edge>>>,
171    spawned_by: FxHashMap<usize, (usize, usize)>,
172}
173
174/// The response that emitted a tool call. A call in a response Codex never recorded is
175/// attributed to the latest earlier recorded request of the same thread.
176fn call_source(
177    thread: &RolloutThread,
178    tool: &CodexTool,
179    report: &mut ExportReport,
180) -> Option<usize> {
181    if let Some(source) = tool.source_request {
182        return Some(source);
183    }
184    let source = thread
185        .requests
186        .iter()
187        .rposition(|request| request.start_ms <= tool.started_at_ms)?;
188    report.reattributed_calls += 1;
189    Some(source)
190}
191
192fn link_threads(threads: &[RolloutThread], report: &mut ExportReport) -> Links {
193    let thread_index = threads
194        .iter()
195        .enumerate()
196        .map(|(index, thread)| (thread.thread_id.as_str(), index))
197        .collect::<FxHashMap<_, _>>();
198    let mut inboxes = vec![Vec::<Queued>::new(); threads.len()];
199    for (sender, thread) in threads.iter().enumerate() {
200        let tools_by_call = thread
201            .tools
202            .iter()
203            .map(|tool| (tool.call_id.as_str(), tool))
204            .collect::<FxHashMap<_, _>>();
205        for send in &thread.sends {
206            let Some(&recipient) = thread_index.get(send.recipient_thread_id.as_str()) else {
207                report.unresolved_agents += 1;
208                continue;
209            };
210            let Some(tool) = tools_by_call.get(send.call_id.as_str()) else {
211                continue;
212            };
213            let kind = match tool.name.as_str() {
214                "spawn_agent" => QueuedKind::Spawn,
215                "followup_task" => QueuedKind::Task,
216                "send_message" => QueuedKind::Message,
217                _ => continue,
218            };
219            let Some(source) = call_source(thread, tool, report) else {
220                continue;
221            };
222            inboxes[recipient].push(Queued {
223                queued_at_ms: send.queued_at_ms,
224                sender: (sender, source),
225                kind,
226            });
227        }
228        // A finished child queues its final answer for the parent.
229        for completion in &thread.child_completions {
230            let Some(&child) = thread_index.get(completion.child_thread_id.as_str()) else {
231                report.unresolved_agents += 1;
232                continue;
233            };
234            let Some(last) = threads[child]
235                .requests
236                .iter()
237                .rposition(|request| request.end_ms <= completion.queued_at_ms)
238            else {
239                continue;
240            };
241            inboxes[sender].push(Queued {
242                queued_at_ms: completion.queued_at_ms,
243                sender: (child, last),
244                kind: QueuedKind::Message,
245            });
246        }
247    }
248    for inbox in &mut inboxes {
249        inbox.sort_by_key(|queued| queued.queued_at_ms);
250    }
251
252    let mut edges = threads
253        .iter()
254        .map(|thread| vec![Vec::new(); thread.requests.len()])
255        .collect::<Vec<_>>();
256    let mut spawned_by = FxHashMap::default();
257    let mut push = |recipient: usize, request: usize, edge: Edge| {
258        let request_edges: &mut Vec<Edge> = &mut edges[recipient][request];
259        if request_edges.contains(&edge) {
260            return false;
261        }
262        request_edges.push(edge);
263        true
264    };
265
266    for (recipient, inbox) in inboxes.iter().enumerate() {
267        let thread = &threads[recipient];
268        for queued in inbox
269            .iter()
270            .filter(|queued| queued.kind != QueuedKind::Message)
271        {
272            // A spawn starts the child's first turn. The child may stamp that turn's input in
273            // the same millisecond as the parent's spawn record, so target it directly.
274            let target = if queued.kind == QueuedKind::Spawn {
275                spawned_by.entry(recipient).or_insert(queued.sender);
276                Some(0)
277            } else {
278                thread
279                    .requests
280                    .iter()
281                    .position(|request| request.start_ms >= queued.queued_at_ms)
282            };
283            let Some(target) = target else {
284                report.undelivered_tasks += 1;
285                continue;
286            };
287            let from_parent = thread.parent_thread_id.as_deref()
288                == Some(threads[queued.sender.0].thread_id.as_str());
289            let relation = if from_parent {
290                AgenticDependencyRelation::Spawn
291            } else {
292                AgenticDependencyRelation::Join
293            };
294            let edge = Edge {
295                sender: queued.sender,
296                relation,
297            };
298            report.task_edges += usize::from(push(recipient, target, edge));
299        }
300        // A wait ends on the first message queued after its request started, or times out. A
301        // wait with no visible message either timed out or waited on a sender outside the export.
302        for tool in thread.tools.iter().filter(|tool| tool.name == "wait_agent") {
303            let Some(ended_at_ms) = tool.ended_at_ms else {
304                continue;
305            };
306            let Some(source) = call_source(thread, tool, report) else {
307                continue;
308            };
309            let window_start_ms = thread.requests[source].start_ms;
310            let Some(queued) = inbox
311                .iter()
312                .find(|queued| (window_start_ms..=ended_at_ms).contains(&queued.queued_at_ms))
313            else {
314                report.unlinked_waits += 1;
315                continue;
316            };
317            let Some(consumer) = thread
318                .requests
319                .iter()
320                .position(|request| request.start_ms >= ended_at_ms)
321            else {
322                continue;
323            };
324            let edge = Edge {
325                sender: queued.sender,
326                relation: AgenticDependencyRelation::Join,
327            };
328            report.wait_edges += usize::from(push(recipient, consumer, edge));
329        }
330    }
331    Links { edges, spawned_by }
332}
333
334#[derive(Serialize)]
335struct NoToolMetadata;
336
337/// Writes one Dynamo request-trace row per recorded response, followed by its tool rows.
338pub fn write_request_trace(
339    output_path: &Path,
340    threads: &[RolloutThread],
341    config: ExportConfig,
342    report: &mut ExportReport,
343) -> Result<()> {
344    if config.block_size == 0 {
345        bail!("block_size must be greater than 0");
346    }
347    let export_ids = threads
348        .iter()
349        .map(|thread| {
350            if config.preserve_session_ids {
351                thread.thread_id.clone()
352            } else {
353                anonymized_session_id(&thread.thread_id)
354            }
355        })
356        .collect::<Vec<_>>();
357    let request_id =
358        |(thread, request): (usize, usize)| format!("codex:{}:{request}", export_ids[thread]);
359    let thread_index = threads
360        .iter()
361        .enumerate()
362        .map(|(index, thread)| (thread.thread_id.as_str(), index))
363        .collect::<FxHashMap<_, _>>();
364
365    let links = link_threads(threads, report);
366    // A forked child's first request inherits the context of the request that spawned it.
367    let mut fork_sources = FxHashMap::default();
368    let mut fork_children = FxHashMap::<(usize, usize), usize>::default();
369    for (child, thread) in threads.iter().enumerate() {
370        let Some(&source) = links.spawned_by.get(&child) else {
371            continue;
372        };
373        if thread.forked_from_id.as_deref() == Some(threads[source.0].thread_id.as_str()) {
374            fork_sources.insert(child, source);
375            *fork_children.entry(source).or_default() += 1;
376        }
377    }
378
379    let mut order = threads
380        .iter()
381        .enumerate()
382        .flat_map(|(thread, rollout)| {
383            (0..rollout.requests.len()).map(move |request| (thread, request))
384        })
385        .collect::<Vec<_>>();
386    order.sort_by_key(|&(thread, request)| {
387        let request_row = &threads[thread].requests[request];
388        (request_row.start_ms, request_row.end_ms, thread, request)
389    });
390    let Some(&(first_thread, first_request)) = order.first() else {
391        bail!("no Codex requests to export");
392    };
393    let trace_start_ms = threads[first_thread].requests[first_request].start_ms;
394
395    let tools_by_request = threads
396        .iter()
397        .map(|thread| {
398            let mut grouped = vec![Vec::new(); thread.requests.len()];
399            for tool in &thread.tools {
400                if let (Some(source), Some(_)) = (tool.source_request, tool.ended_at_ms) {
401                    grouped[source].push(tool);
402                }
403            }
404            grouped
405        })
406        .collect::<Vec<_>>();
407    let seeds = export_ids
408        .iter()
409        .map(|id| synthetic_stream_seed(id))
410        .collect::<Vec<_>>();
411
412    let mut output = BufWriter::new(
413        File::create(output_path)
414            .with_context(|| format!("failed to create {}", output_path.display()))?,
415    );
416    let mut prefix_pool = PrefixPool::default();
417    let mut previous_bases: Vec<Option<ReplayBase>> = threads.iter().map(|_| None).collect();
418    let mut fork_bases: FxHashMap<(usize, usize), ReplayBase> = FxHashMap::default();
419    for (thread_index_value, request_index) in order {
420        let thread = &threads[thread_index_value];
421        let request = &thread.requests[request_index];
422        let input_length = usize::try_from(request.usage.input_tokens)?;
423        let cached_length = usize::try_from(request.usage.cached_input_tokens)?;
424
425        let fork_base = previous_bases[thread_index_value]
426            .is_none()
427            .then(|| fork_sources.get(&thread_index_value))
428            .flatten()
429            .and_then(|source| {
430                let remaining = fork_children.get_mut(source)?;
431                *remaining -= 1;
432                if *remaining == 0 {
433                    fork_bases.remove(source)
434                } else {
435                    fork_bases.get(source).cloned()
436                }
437            });
438        let (source, blocks_counter) = match (&previous_bases[thread_index_value], &fork_base) {
439            (Some(base), _) => (Some(base), &mut report.cache_prefix_blocks),
440            (None, Some(base)) => (Some(base), &mut report.forked_prefix_blocks),
441            (None, None) if cached_length > 0 => {
442                // A fork whose spawning request is outside the export shares its inherited
443                // context with its siblings, not with every thread in the working directory.
444                let key = match &thread.forked_from_id {
445                    Some(parent) => prefix_pool_key("codex-fork", parent, &thread.cwd),
446                    None => prefix_pool_key("codex", &request.model, &thread.cwd),
447                };
448                (
449                    Some(prefix_pool.prefix(&key, cached_length, config.block_size)?),
450                    &mut report.pooled_prefix_blocks,
451                )
452            }
453            (None, None) => (None, &mut report.cache_prefix_blocks),
454        };
455        let (tokens, shared_tokens) = usage_shaped_tokens(
456            seeds[thread_index_value],
457            request_index,
458            input_length,
459            cached_length,
460            source.map(|base| base.tokens.as_slice()),
461        );
462        *blocks_counter += shared_tokens / config.block_size;
463        let replay = ReplayBase::derive(source, shared_tokens, tokens, config.block_size)?;
464
465        let id = request_id((thread_index_value, request_index));
466        let dependencies = links.edges[thread_index_value][request_index]
467            .iter()
468            .map(|edge| ReplayDependency {
469                request_id: request_id(edge.sender),
470                relation: edge.relation,
471                trigger: AgenticDependencyTrigger::Completion,
472            })
473            .collect::<Vec<_>>();
474        let parent_session_id = thread
475            .parent_thread_id
476            .as_deref()
477            .and_then(|parent| thread_index.get(parent))
478            .map(|parent| export_ids[*parent].as_str());
479        let agent_context = AgentContext {
480            session_id: &export_ids[thread_index_value],
481            parent_session_id,
482        };
483        let row = TraceLine {
484            timestamp: nonnegative_ms(request.end_ms - trace_start_ms),
485            event: RequestEndEvent {
486                schema: REQUEST_TRACE_SCHEMA,
487                event_type: "request_end",
488                event_time_unix_ms: nonnegative_ms(request.end_ms),
489                event_source: HARNESS_EVENT_SOURCE,
490                agent_context: &agent_context,
491                request: RequestFields {
492                    request_id: &id,
493                    model: &request.model,
494                    input_tokens: replay.tokens.len(),
495                    output_tokens: usize::try_from(request.usage.output_tokens)?,
496                    request_received_ms: nonnegative_ms(request.start_ms),
497                    total_time_ms: (request.end_ms - request.start_ms).max(0) as f64,
498                    replay: ReplayFields {
499                        trace_block_size: config.block_size,
500                        input_length: replay.tokens.len(),
501                        input_sequence_hashes: &replay.hashes,
502                        dependencies: &dependencies,
503                    },
504                    cached_tokens: Some(cached_length),
505                    claude: None,
506                },
507            },
508        };
509        write_json_line(&mut output, &row)?;
510        for tool in &tools_by_request[thread_index_value][request_index] {
511            let ended_at_ms = tool.ended_at_ms.expect("grouped tools have ended");
512            let tool_row = TraceLine {
513                timestamp: nonnegative_ms(ended_at_ms - trace_start_ms),
514                event: ToolEvent {
515                    schema: REQUEST_TRACE_SCHEMA,
516                    event_type: "tool_end",
517                    event_time_unix_ms: nonnegative_ms(ended_at_ms),
518                    event_source: HARNESS_EVENT_SOURCE,
519                    agent_context: &agent_context,
520                    tool: ToolFields::<NoToolMetadata> {
521                        tool_call_id: &tool.call_id,
522                        tool_class: &tool.name,
523                        claude: None,
524                        started_at_unix_ms: nonnegative_ms(tool.started_at_ms),
525                        ended_at_unix_ms: nonnegative_ms(ended_at_ms),
526                        duration_ms: (ended_at_ms - tool.started_at_ms).max(0) as f64,
527                        status: "succeeded",
528                        output_bytes: None,
529                        error_type: None,
530                    },
531                },
532            };
533            write_json_line(&mut output, &tool_row)?;
534            report.tools += 1;
535        }
536        report.requests += 1;
537        report.compactions += usize::from(request.compaction);
538
539        if fork_children.contains_key(&(thread_index_value, request_index)) {
540            fork_bases.insert((thread_index_value, request_index), replay.clone());
541        }
542        previous_bases[thread_index_value] =
543            (request_index + 1 < thread.requests.len()).then_some(replay);
544    }
545    output.flush()?;
546
547    report.threads = threads.len();
548    for thread in threads {
549        report.preempted_responses += thread.unrecorded.preempted;
550        report.aborted_responses += thread.unrecorded.aborted;
551        report.untracked_responses += thread.unrecorded.untracked;
552        report.unmatched_compactions += thread.unmatched_compactions;
553    }
554    Ok(())
555}
556
557fn write_json_line(writer: &mut impl Write, value: &impl Serialize) -> Result<()> {
558    serde_json::to_writer(&mut *writer, value)?;
559    writer.write_all(b"\n")?;
560    Ok(())
561}
562
563fn nonnegative_ms(value: i64) -> u64 {
564    value.max(0) as u64
565}
566
567#[cfg(test)]
568mod tests {
569    use super::{ExportConfig, ExportReport, load_threads, write_request_trace};
570    use crate::coding::codex::parser::tests::{line, rollout};
571    use dynamo_data_gen::AgenticDependencyRelation;
572    use dynamo_data_gen::request_trace::agentic::lower_agentic_mooncake_rows;
573    use dynamo_data_gen::request_trace::load::load_request_trace_records;
574    use serde_json::{Value, json};
575    use tempfile::TempDir;
576
577    fn usage(second: u32, response_id: &str, input: u64, cached: u64, output: u64) -> Value {
578        line(
579            second,
580            "token_usage_record",
581            json!({"response_id": response_id, "usage": {"input_tokens": input, "cached_input_tokens": cached, "output_tokens": output}}),
582        )
583    }
584
585    fn activity(second: u32, id: &str, kind: &str, thread: &str) -> Value {
586        line(
587            second,
588            "event_msg",
589            json!({"type": "item_completed", "item": {"type": "SubAgentActivity", "id": id, "kind": kind, "agent_thread_id": thread}}),
590        )
591    }
592
593    fn call(second: u32, call_id: &str, name: &str) -> Value {
594        line(
595            second,
596            "response_item",
597            json!({"type": "function_call", "call_id": call_id, "name": name}),
598        )
599    }
600
601    fn output(second: u32, call_id: &str) -> Value {
602        line(
603            second,
604            "response_item",
605            json!({"type": "function_call_output", "call_id": call_id}),
606        )
607    }
608
609    fn export(rollouts: &[&tempfile::NamedTempFile]) -> (ExportReport, Vec<Value>) {
610        let mut report = ExportReport::default();
611        let paths = rollouts
612            .iter()
613            .map(|rollout| rollout.path().to_path_buf())
614            .collect::<Vec<_>>();
615        let threads = load_threads(&paths, 2, &mut report).unwrap();
616        let output_dir = TempDir::new().unwrap();
617        let output_path = output_dir.path().join("codex.jsonl");
618        let config = ExportConfig {
619            block_size: 64,
620            preserve_session_ids: true,
621        };
622        write_request_trace(&output_path, &threads, config, &mut report).unwrap();
623        let rows = std::fs::read_to_string(&output_path)
624            .unwrap()
625            .lines()
626            .map(|line| serde_json::from_str(line).unwrap())
627            .filter(|row: &Value| row["event"]["event_type"] == "request_end")
628            .collect();
629        (report, rows)
630    }
631
632    fn forked_child(id: &str, spawned_at: u32) -> tempfile::NamedTempFile {
633        rollout(&[
634            line(
635                spawned_at,
636                "session_meta",
637                json!({"id": id, "parent_thread_id": "parent", "forked_from_id": "parent", "subagent_history_start_ordinal": 2}),
638            ),
639            line(spawned_at, "session_meta", json!({"id": "parent"})),
640            line(
641                spawned_at + 1,
642                "response_item",
643                json!({"type": "agent_message", "author": "/root"}),
644            ),
645            line(
646                spawned_at + 2,
647                "response_item",
648                json!({"type": "reasoning"}),
649            ),
650            usage(spawned_at + 3, "resp-c1", 1_200, 1_000, 30),
651        ])
652    }
653
654    #[test]
655    fn forks_spawned_by_one_request_each_inherit_its_context() {
656        let parent = rollout(&[
657            line(0, "session_meta", json!({"id": "parent"})),
658            line(
659                1,
660                "response_item",
661                json!({"type": "message", "role": "user"}),
662            ),
663            call(2, "call-a", "spawn_agent"),
664            call(2, "call-b", "spawn_agent"),
665            usage(3, "resp-p1", 1_000, 0, 10),
666            activity(4, "call-a", "started", "child-a"),
667            activity(4, "call-b", "started", "child-b"),
668        ]);
669        let (report, _) = export(&[
670            &parent,
671            &forked_child("child-a", 4),
672            &forked_child("child-b", 4),
673        ]);
674        assert_eq!(report.task_edges, 2);
675        assert_eq!(report.forked_prefix_blocks, 2 * (1_000 / 64));
676    }
677
678    #[test]
679    fn waits_link_only_messages_queued_while_waiting() {
680        // The child finishes before the parent's waiting request starts, so its answer arrives
681        // with that request and cannot be what the wait returned on.
682        let parent = rollout(&[
683            line(0, "session_meta", json!({"id": "parent"})),
684            line(
685                1,
686                "response_item",
687                json!({"type": "message", "role": "user"}),
688            ),
689            call(2, "call-spawn", "spawn_agent"),
690            usage(3, "resp-p1", 1_000, 0, 10),
691            activity(4, "call-spawn", "started", "child"),
692            output(4, "call-spawn"),
693            activity(20, "subagent-completed-turn", "completed", "child"),
694            line(
695                30,
696                "response_item",
697                json!({"type": "message", "role": "user"}),
698            ),
699            call(31, "call-wait", "wait_agent"),
700            usage(32, "resp-p2", 1_100, 1_000, 5),
701            output(70, "call-wait"),
702            line(71, "response_item", json!({"type": "reasoning"})),
703            usage(72, "resp-p3", 1_200, 1_100, 5),
704        ]);
705        let child = rollout(&[
706            line(
707                4,
708                "session_meta",
709                json!({"id": "child", "parent_thread_id": "parent"}),
710            ),
711            line(
712                5,
713                "response_item",
714                json!({"type": "agent_message", "author": "/root"}),
715            ),
716            line(6, "response_item", json!({"type": "reasoning"})),
717            usage(10, "resp-c1", 500, 0, 30),
718        ]);
719        let (report, rows) = export(&[&parent, &child]);
720        assert_eq!((report.wait_edges, report.unlinked_waits), (0, 1));
721        let consumer = rows
722            .iter()
723            .find(|row| row["event"]["request"]["request_id"] == "codex:parent:2")
724            .unwrap();
725        assert!(consumer["event"]["request"]["replay"]["dependencies"].is_null());
726    }
727
728    #[test]
729    fn messages_and_calls_in_unrecorded_responses_still_link() {
730        let parent = rollout(&[
731            line(0, "session_meta", json!({"id": "parent"})),
732            line(
733                1,
734                "response_item",
735                json!({"type": "message", "role": "user"}),
736            ),
737            line(2, "response_item", json!({"type": "reasoning"})),
738            usage(3, "resp-p1", 1_000, 0, 10),
739            // This response is cut off by a queued message, so its spawn has no usage record.
740            call(5, "call-spawn", "spawn_agent"),
741            line(
742                6,
743                "response_item",
744                json!({"type": "agent_message", "author": "/root/other"}),
745            ),
746            activity(6, "call-spawn", "started", "child"),
747            call(8, "call-message", "send_message"),
748            usage(9, "resp-p2", 1_100, 1_000, 5),
749            activity(9, "call-message", "interacted", "child"),
750        ]);
751        let child = rollout(&[
752            line(
753                6,
754                "session_meta",
755                json!({"id": "child", "parent_thread_id": "missing-root"}),
756            ),
757            line(
758                7,
759                "response_item",
760                json!({"type": "agent_message", "author": "/root"}),
761            ),
762            line(8, "response_item", json!({"type": "reasoning"})),
763            usage(10, "resp-c1", 500, 0, 30),
764            line(
765                11,
766                "response_item",
767                json!({"type": "agent_message", "author": "/root"}),
768            ),
769            line(12, "response_item", json!({"type": "reasoning"})),
770            usage(13, "resp-c2", 600, 500, 30),
771        ]);
772        let (report, rows) = export(&[&parent, &child]);
773        assert_eq!(report.reattributed_calls, 1);
774        // The spawn links; the queued message starts no turn.
775        assert_eq!(report.task_edges, 1);
776        let child_rows = rows
777            .iter()
778            .filter(|row| row["event"]["agent_context"]["session_id"] == "child")
779            .collect::<Vec<_>>();
780        assert_eq!(
781            child_rows[0]["event"]["request"]["replay"]["dependencies"][0]["request_id"],
782            "codex:parent:0"
783        );
784        assert!(child_rows[1]["event"]["request"]["replay"]["dependencies"].is_null());
785        // The parent named by the child is not in the export, so no parent session is claimed.
786        assert!(child_rows[0]["event"]["agent_context"]["parent_session_id"].is_null());
787    }
788
789    #[test]
790    fn spawned_fork_and_wait_become_explicit_edges() {
791        let parent = rollout(&[
792            line(0, "session_meta", json!({"id": "parent", "cwd": "/repo"})),
793            line(0, "turn_context", json!({"model": "gpt"})),
794            line(
795                1,
796                "response_item",
797                json!({"type": "message", "role": "user"}),
798            ),
799            call(2, "call-spawn", "spawn_agent"),
800            usage(3, "resp-p1", 1_000, 0, 10),
801            activity(4, "call-spawn", "started", "child"),
802            output(4, "call-spawn"),
803            call(5, "call-wait", "wait_agent"),
804            usage(6, "resp-p2", 1_100, 1_000, 5),
805            activity(20, "subagent-completed-turn", "completed", "child"),
806            output(20, "call-wait"),
807            line(
808                22,
809                "response_item",
810                json!({"type": "message", "role": "assistant"}),
811            ),
812            usage(23, "resp-p3", 1_300, 1_100, 20),
813        ]);
814        let child = rollout(&[
815            line(
816                4,
817                "session_meta",
818                json!({"id": "child", "parent_thread_id": "parent", "forked_from_id": "parent", "subagent_history_start_ordinal": 3, "cwd": "/repo"}),
819            ),
820            line(4, "session_meta", json!({"id": "parent"})),
821            line(
822                4,
823                "response_item",
824                json!({"type": "message", "role": "user"}),
825            ),
826            line(5, "turn_context", json!({"model": "gpt"})),
827            line(
828                5,
829                "response_item",
830                json!({"type": "agent_message", "author": "/root"}),
831            ),
832            line(7, "response_item", json!({"type": "reasoning"})),
833            usage(10, "resp-c1", 1_200, 1_000, 30),
834            line(
835                15,
836                "response_item",
837                json!({"type": "message", "role": "assistant"}),
838            ),
839            usage(18, "resp-c2", 1_300, 1_200, 40),
840        ]);
841
842        let mut report = ExportReport::default();
843        let threads = load_threads(
844            &[parent.path().to_path_buf(), child.path().to_path_buf()],
845            2,
846            &mut report,
847        )
848        .unwrap();
849        let output_dir = TempDir::new().unwrap();
850        let output_path = output_dir.path().join("codex.jsonl");
851        write_request_trace(
852            &output_path,
853            &threads,
854            ExportConfig {
855                block_size: 64,
856                preserve_session_ids: true,
857            },
858            &mut report,
859        )
860        .unwrap();
861        assert_eq!((report.task_edges, report.wait_edges), (1, 1));
862        assert_eq!(report.forked_prefix_blocks, 1_000 / 64);
863
864        let hashes = |request_id: &str| -> Vec<u64> {
865            std::fs::read_to_string(&output_path)
866                .unwrap()
867                .lines()
868                .map(|line| serde_json::from_str::<Value>(line).unwrap())
869                .find(|row| row["event"]["request"]["request_id"] == request_id)
870                .map(|row| {
871                    serde_json::from_value(
872                        row["event"]["request"]["replay"]["input_sequence_hashes"].clone(),
873                    )
874                    .unwrap()
875                })
876                .unwrap()
877        };
878        // The forked child's first request reuses the context of the request that spawned it.
879        assert_eq!(
880            hashes("codex:child:0")[..15],
881            hashes("codex:parent:0")[..15]
882        );
883
884        let loaded = load_request_trace_records(&[output_path]).unwrap();
885        let mut rows = Vec::new();
886        lower_agentic_mooncake_rows(loaded, |_, row| {
887            rows.push(row);
888            Ok(())
889        })
890        .unwrap();
891        let edges = |request_id: &str| {
892            rows.iter()
893                .find(|row| row.request_id == request_id)
894                .unwrap()
895                .dependencies
896                .iter()
897                .map(|edge| (edge.request_id.clone(), edge.relation))
898                .collect::<Vec<_>>()
899        };
900        assert_eq!(
901            edges("codex:child:0"),
902            [(
903                "codex:parent:0".to_string(),
904                AgenticDependencyRelation::Spawn
905            )]
906        );
907        assert_eq!(
908            edges("codex:parent:2"),
909            [
910                ("codex:child:1".to_string(), AgenticDependencyRelation::Join),
911                (
912                    "codex:parent:1".to_string(),
913                    AgenticDependencyRelation::Sequence
914                ),
915            ]
916        );
917    }
918}