1use 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#[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
87pub 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#[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,
155 Task,
157 Message,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163struct Edge {
164 sender: (usize, usize),
165 relation: AgenticDependencyRelation,
166}
167
168struct Links {
170 edges: Vec<Vec<Vec<Edge>>>,
171 spawned_by: FxHashMap<usize, (usize, usize)>,
172}
173
174fn 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 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 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 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
337pub 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 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 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 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 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 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 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 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}