1use std::collections::HashSet;
9use std::fs::File;
10use std::io::BufRead;
11use std::io::BufReader;
12use std::io::Read;
13use std::path::Path;
14use std::path::PathBuf;
15
16use anyhow::Context;
17use anyhow::Result;
18use anyhow::anyhow;
19use anyhow::bail;
20use flate2::read::MultiGzDecoder;
21use serde::de::IgnoredAny;
22use serde::{Deserialize, Serialize};
23
24use crate::{AgenticDependencyRelation, AgenticDependencyTrigger};
25
26#[derive(Debug, Clone, Deserialize)]
27pub(crate) struct RequestTraceRecord {
28 pub(crate) schema: TraceSchema,
29 pub(crate) event_type: String,
30 pub(crate) event_time_unix_ms: u64,
31 #[serde(default)]
32 pub(crate) agent_context: Option<AgentContextFields>,
33 #[serde(default)]
34 pub(crate) request: Option<RequestTraceRequestMetrics>,
35 #[serde(default)]
36 pub(crate) tool: Option<RequestTraceToolEventMetrics>,
37}
38
39#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
40pub(crate) enum TraceSchema {
41 #[serde(rename = "dynamo.request.trace.v1")]
42 RequestV1,
43}
44
45#[derive(Debug, Clone, Default, Deserialize)]
46pub(crate) struct AgentContextFields {
47 pub(crate) session_id: String,
48 #[serde(default)]
49 pub(crate) parent_session_id: Option<String>,
50}
51
52#[derive(Debug, Clone, Default, Deserialize)]
53pub(crate) struct RequestTraceRequestMetrics {
54 pub(crate) request_id: String,
55 #[serde(default)]
56 pub(crate) x_request_id: Option<String>,
57 #[serde(default)]
58 pub(crate) model: Option<String>,
59 #[serde(default)]
60 pub(crate) input_tokens: Option<u64>,
61 #[serde(default)]
62 pub(crate) output_tokens: Option<u64>,
63 #[serde(default)]
64 pub(crate) cached_tokens: Option<u64>,
65 #[serde(default)]
66 pub(crate) request_received_ms: Option<u64>,
67 #[serde(default)]
68 pub(crate) prefill_wait_time_ms: Option<f64>,
69 #[serde(default)]
70 pub(crate) prefill_time_ms: Option<f64>,
71 #[serde(default)]
72 pub(crate) ttft_ms: Option<f64>,
73 #[serde(default)]
74 pub(crate) total_time_ms: Option<f64>,
75 #[serde(default)]
76 pub(crate) avg_itl_ms: Option<f64>,
77 #[serde(default)]
78 pub(crate) kv_hit_rate: Option<f64>,
79 #[serde(default)]
80 pub(crate) kv_transfer_estimated_latency_ms: Option<f64>,
81 #[serde(default)]
82 pub(crate) queue_depth: Option<u64>,
83 #[serde(default)]
84 pub(crate) worker: Option<RequestTraceWorkerInfo>,
85 #[serde(default)]
86 pub(crate) replay: Option<RequestTraceReplayMetrics>,
87 #[serde(default)]
88 pub(crate) finish_reason_metadata: Option<RequestTraceFinishReasonMetadata>,
89}
90
91#[derive(Debug, Clone, Deserialize, Serialize)]
92pub(crate) struct RequestTraceWorkerInfo {
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub(crate) prefill_worker_id: Option<u64>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub(crate) prefill_dp_rank: Option<u32>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub(crate) decode_worker_id: Option<u64>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub(crate) decode_dp_rank: Option<u32>,
101}
102
103#[derive(Debug, Clone, Deserialize, Serialize)]
104pub(crate) struct RequestTraceFinishReasonMetadata {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub(crate) finish_reason: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub(crate) backend_finish_reason: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub(crate) stop_reason: Option<serde_json::Value>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub(crate) tool_calls: Vec<RequestTraceToolCall>,
113}
114
115#[derive(Debug, Clone, Deserialize, Serialize)]
116pub(crate) struct RequestTraceToolCall {
117 pub(crate) choice_index: u32,
118 pub(crate) tool_call_index: u32,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub(crate) id: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub(crate) name: Option<String>,
123}
124
125#[derive(Debug, Clone, Deserialize)]
126pub(crate) struct RequestTraceReplayMetrics {
127 pub(crate) trace_block_size: usize,
128 pub(crate) input_length: usize,
129 pub(crate) input_sequence_hashes: Vec<u64>,
130 #[serde(default)]
133 pub(crate) dependencies: Vec<ReplayDependencyRecord>,
134}
135
136#[derive(Debug, Clone, Deserialize)]
138pub(crate) struct ReplayDependencyRecord {
139 pub(crate) request_id: String,
140 pub(crate) relation: AgenticDependencyRelation,
141 pub(crate) trigger: AgenticDependencyTrigger,
142}
143
144#[derive(Debug, Clone, Deserialize)]
145pub(crate) struct RequestTraceToolEventMetrics {
146 pub(crate) tool_call_id: String,
147 pub(crate) tool_class: String,
148 #[serde(default)]
149 pub(crate) claude: Option<ClaudeToolReplayMetrics>,
150 #[serde(default)]
151 pub(crate) started_at_unix_ms: Option<u64>,
152 #[serde(default)]
153 pub(crate) ended_at_unix_ms: Option<u64>,
154 #[serde(default)]
155 pub(crate) duration_ms: Option<f64>,
156 #[serde(default)]
157 pub(crate) status: Option<String>,
158 #[serde(default)]
159 pub(crate) output_bytes: Option<u64>,
160 #[serde(default)]
161 pub(crate) output_tokens: Option<u64>,
162 #[serde(default)]
163 pub(crate) error_type: Option<String>,
164}
165
166#[derive(Debug, Clone, Deserialize)]
172pub(crate) struct ClaudeToolReplayMetrics {
173 pub(crate) source_request_id: String,
175 #[serde(default)]
177 pub(crate) consumer_request_id: Option<String>,
178 #[serde(default)]
180 pub(crate) child_session_id: Option<String>,
181 pub(crate) execution_mode: String,
183}
184
185#[derive(Debug, Deserialize)]
186#[serde(untagged)]
187enum JsonLineEnvelope {
188 Object(TraceRecordEnvelope),
189 Other(IgnoredAny),
190}
191
192#[derive(Debug, Deserialize)]
193struct TraceRecordEnvelope {
194 #[serde(default)]
195 event_type: Option<String>,
196 #[serde(default)]
197 event: Option<TraceEventEnvelope>,
198}
199
200#[derive(Debug, Deserialize)]
201#[serde(untagged)]
202enum TraceEventEnvelope {
203 Object(TraceEventFields),
204 Other(IgnoredAny),
205}
206
207#[derive(Debug, Deserialize)]
208struct TraceEventFields {
209 #[serde(default)]
210 event_type: Option<String>,
211}
212
213#[derive(Debug, Deserialize)]
214struct WrappedTraceRecord {
215 event: RequestTraceRecord,
216}
217
218#[derive(Debug, Clone)]
219pub struct RequestEntry {
220 pub(crate) start_ms: i64,
221 pub(crate) end_ms: i64,
222 pub(crate) agent_context: Option<AgentContextFields>,
223 pub(crate) request: RequestTraceRequestMetrics,
224 pub(crate) replay: RequestTraceReplayMetrics,
225}
226
227#[derive(Debug, Clone)]
228pub struct ToolEntry {
229 pub(crate) session_id: String,
230 pub(crate) start_ms: i64,
231 pub(crate) end_ms: i64,
232 pub(crate) tool_call_id: String,
233 pub(crate) tool_class: String,
234 pub(crate) claude: Option<ClaudeToolReplayMetrics>,
235 pub(crate) status: String,
236 pub(crate) duration_ms: f64,
237 pub(crate) output_bytes: Option<u64>,
238 pub(crate) output_tokens: Option<u64>,
239 pub(crate) error_type: Option<String>,
240}
241
242#[derive(Debug, Default)]
243pub struct LoadedAgentTrace {
244 pub requests: Vec<RequestEntry>,
245 pub tools: Vec<ToolEntry>,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum RequestTraceMode {
250 Standard,
251 Agentic,
252}
253
254impl LoadedAgentTrace {
255 pub fn mode(&self) -> Result<RequestTraceMode> {
256 if self.requests.is_empty() {
257 bail!("Dynamo request trace contains no requests");
258 }
259 let contextual_requests = self
260 .requests
261 .iter()
262 .filter(|request| request.agent_context.is_some())
263 .count();
264 match contextual_requests {
265 0 => Ok(RequestTraceMode::Standard),
266 count if count == self.requests.len() => Ok(RequestTraceMode::Agentic),
267 _ => bail!("Dynamo request trace cannot mix requests with and without agent_context"),
268 }
269 }
270
271 pub fn ensure_agentic_compatible(&self) -> Result<()> {
272 if self
273 .requests
274 .iter()
275 .any(|request| request.agent_context.is_none())
276 {
277 bail!("agentic lowering requires agent_context on every request");
278 }
279 Ok(())
280 }
281}
282
283pub fn load_request_trace_records(paths: &[PathBuf]) -> Result<LoadedAgentTrace> {
286 let mut loaded = LoadedAgentTrace::default();
287 let mut request_ids = HashSet::new();
288
289 for path in paths {
290 let reader = open_trace_reader(path)?;
291 for (line_index, line) in reader.lines().enumerate() {
292 let line = line
293 .with_context(|| format!("failed to read {}:{}", path.display(), line_index + 1))?;
294 if line.trim().is_empty() {
295 continue;
296 }
297 let Some(record) = parse_trace_record(&line).with_context(|| {
298 format!("failed to parse {}:{}", path.display(), line_index + 1)
299 })?
300 else {
301 continue;
302 };
303 let _schema = record.schema;
304 if record.event_type == "request_payload" {
305 continue;
306 }
307 if !matches!(
308 record.event_type.as_str(),
309 "request_end" | "tool_start" | "tool_end" | "tool_error"
310 ) {
311 bail!(
312 "request trace schema only supports request_end/tool_* and request_payload events, got {} at {}:{}",
313 record.event_type,
314 path.display(),
315 line_index + 1
316 );
317 }
318 if record.event_type == "request_end" {
319 let entry = request_entry(record).with_context(|| {
320 format!(
321 "invalid request_end at {}:{}",
322 path.display(),
323 line_index + 1
324 )
325 })?;
326 if !request_ids.insert(entry.request.request_id.clone()) {
327 bail!(
328 "duplicate request_id {} at {}:{}",
329 entry.request.request_id,
330 path.display(),
331 line_index + 1
332 );
333 }
334 loaded.requests.push(entry);
335 } else if matches!(record.event_type.as_str(), "tool_end" | "tool_error") {
336 let terminal_event = record.event_type.clone();
337 if let Some(tool) = tool_entry(record, terminal_event) {
338 loaded.tools.push(tool);
339 }
340 }
341 }
342 }
343
344 if loaded.requests.is_empty() {
345 bail!("no request_end records with replay fields found");
346 }
347
348 Ok(loaded)
349}
350
351fn open_trace_reader(path: &Path) -> Result<Box<dyn BufRead>> {
352 let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
353 let reader: Box<dyn Read> = if path.extension().and_then(|ext| ext.to_str()) == Some("gz") {
354 Box::new(MultiGzDecoder::new(file))
355 } else {
356 Box::new(file)
357 };
358 Ok(Box::new(BufReader::new(reader)))
359}
360
361fn parse_trace_record(line: &str) -> Result<Option<RequestTraceRecord>> {
362 let envelope = match serde_json::from_str::<JsonLineEnvelope>(line)? {
363 JsonLineEnvelope::Object(envelope) => envelope,
364 JsonLineEnvelope::Other(_) => return Ok(None),
365 };
366
367 match envelope.event {
368 Some(TraceEventEnvelope::Object(event)) => {
369 if event.event_type.as_deref() == Some("request_payload") {
370 return Ok(None);
371 }
372 Ok(Some(
373 serde_json::from_str::<WrappedTraceRecord>(line)?.event,
374 ))
375 }
376 Some(TraceEventEnvelope::Other(_)) => Ok(None),
377 None => {
378 if envelope.event_type.as_deref() == Some("request_payload") {
379 return Ok(None);
380 }
381 Ok(Some(serde_json::from_str(line)?))
382 }
383 }
384}
385
386fn request_entry(record: RequestTraceRecord) -> Result<RequestEntry> {
387 let request = record
388 .request
389 .ok_or_else(|| anyhow!("request_end record is missing request payload"))?;
390 let replay = request
391 .replay
392 .clone()
393 .ok_or_else(|| anyhow!("request payload is missing replay metrics"))?;
394 if replay.trace_block_size == 0 {
395 bail!("request replay trace_block_size must be greater than 0");
396 }
397 let expected_hashes = replay.input_length.div_ceil(replay.trace_block_size);
398 if replay.input_sequence_hashes.len() != expected_hashes {
399 bail!(
400 "input_length {} with trace_block_size {} requires exactly {} replay hashes, got {}",
401 replay.input_length,
402 replay.trace_block_size,
403 expected_hashes,
404 replay.input_sequence_hashes.len()
405 );
406 }
407 if request.request_received_ms.is_none() {
408 bail!("request trace is missing request_received_ms");
409 }
410 if request.output_tokens.is_none() {
411 bail!("request trace is missing output_tokens");
412 }
413
414 let (start_ms, end_ms) = request_times(record.event_time_unix_ms, &request);
415 Ok(RequestEntry {
416 start_ms,
417 end_ms,
418 agent_context: record.agent_context,
419 request,
420 replay,
421 })
422}
423
424fn tool_entry(record: RequestTraceRecord, terminal_event: String) -> Option<ToolEntry> {
425 let context = record.agent_context?;
426 let tool = record.tool?;
427 let end_ms = tool
428 .ended_at_unix_ms
429 .map(saturating_i64)
430 .unwrap_or_else(|| saturating_i64(record.event_time_unix_ms));
431 let start_ms = tool
432 .started_at_unix_ms
433 .map(saturating_i64)
434 .or_else(|| {
435 tool.duration_ms
436 .map(|duration_ms| end_ms.saturating_sub(duration_ms.max(0.0).round() as i64))
437 })
438 .unwrap_or(end_ms);
439 if end_ms < start_ms {
440 return None;
441 }
442 let duration_ms = tool
443 .duration_ms
444 .unwrap_or_else(|| (end_ms - start_ms).max(0) as f64);
445 let status = tool.status.unwrap_or_else(|| {
446 if terminal_event == "tool_error" {
447 "error".to_string()
448 } else {
449 "succeeded".to_string()
450 }
451 });
452 Some(ToolEntry {
453 session_id: context.session_id,
454 start_ms,
455 end_ms,
456 tool_call_id: tool.tool_call_id,
457 tool_class: tool.tool_class,
458 claude: tool.claude,
459 status,
460 duration_ms,
461 output_bytes: tool.output_bytes,
462 output_tokens: tool.output_tokens,
463 error_type: tool.error_type,
464 })
465}
466
467pub(crate) fn request_times(
468 event_time_unix_ms: u64,
469 request: &RequestTraceRequestMetrics,
470) -> (i64, i64) {
471 let total_ms = request
472 .total_time_ms
473 .map(|value| value.max(0.0).round() as u64)
474 .unwrap_or_else(|| {
475 event_time_unix_ms
476 .saturating_sub(request.request_received_ms.unwrap_or(event_time_unix_ms))
477 });
478 let end_ms = request
479 .request_received_ms
480 .map(|start| start.saturating_add(total_ms))
481 .unwrap_or(event_time_unix_ms);
482 let start_ms = request
483 .request_received_ms
484 .unwrap_or_else(|| event_time_unix_ms.saturating_sub(total_ms));
485 (saturating_i64(start_ms), saturating_i64(end_ms))
486}
487
488pub(crate) fn saturating_i64(value: u64) -> i64 {
489 value.min(i64::MAX as u64) as i64
490}
491
492#[cfg(test)]
493mod tests {
494 use std::io::Write;
495
496 use tempfile::NamedTempFile;
497
498 use super::*;
499
500 #[test]
501 fn request_times_uses_event_time_when_total_duration_is_missing() {
502 let request = RequestTraceRequestMetrics {
503 request_id: "req".to_string(),
504 output_tokens: Some(1),
505 request_received_ms: Some(1_000),
506 ..Default::default()
507 };
508
509 assert_eq!(request_times(1_250, &request), (1_000, 1_250));
510 }
511
512 #[test]
513 fn loads_context_free_request_trace() {
514 let mut file = NamedTempFile::new().unwrap();
515 writeln!(
516 file,
517 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"request":{{"request_id":"req-1","request_received_ms":1000,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":3,"input_sequence_hashes":[11,22]}}}}}}"#
518 )
519 .unwrap();
520
521 let loaded = load_request_trace_records(&[file.path().to_path_buf()]).unwrap();
522 assert_eq!(loaded.requests.len(), 1);
523 assert!(loaded.requests[0].agent_context.is_none());
524 assert_eq!(loaded.requests[0].start_ms, 1_000);
525 assert_eq!(loaded.requests[0].end_ms, 1_100);
526 }
527
528 #[test]
529 fn loads_wrapped_request_trace_record() {
530 let mut file = NamedTempFile::new().unwrap();
531 writeln!(
532 file,
533 r#"{{"timestamp":1,"event":{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"request":{{"request_id":"req-1","request_received_ms":1000,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":3,"input_sequence_hashes":[11,22]}}}}}}}}"#
534 )
535 .unwrap();
536
537 let loaded = load_request_trace_records(&[file.path().to_path_buf()]).unwrap();
538 assert_eq!(loaded.requests.len(), 1);
539 assert_eq!(loaded.requests[0].request.request_id, "req-1");
540 }
541
542 #[test]
543 fn skips_request_payload_records() {
544 let mut file = NamedTempFile::new().unwrap();
545 writeln!(
546 file,
547 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_payload","event_time_unix_ms":1050,"payload":{{"request_id":"req-1","endpoint":"openai.chat_completion","model":"test","request":{{"model":"test","messages":[{{"role":"user","content":"hi"}}]}},"payload_complete":true}}}}"#
548 )
549 .unwrap();
550 let large_payload = "x".repeat(4096);
551 writeln!(
552 file,
553 "{}",
554 serde_json::json!({
555 "timestamp": 1051,
556 "event": {
557 "schema": "dynamo.request.trace.v1",
558 "event_type": "request_payload",
559 "event_time_unix_ms": 1051,
560 "payload": {
561 "request_id": "req-1",
562 "endpoint": "openai.chat_completion",
563 "model": "test",
564 "request": {
565 "model": "test",
566 "messages": [{
567 "role": "user",
568 "content": large_payload.clone(),
569 }],
570 },
571 "response": {
572 "choices": [{
573 "message": {
574 "role": "assistant",
575 "content": large_payload,
576 },
577 }],
578 },
579 "payload_complete": true,
580 },
581 },
582 })
583 )
584 .unwrap();
585 writeln!(
586 file,
587 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"request":{{"request_id":"req-1","request_received_ms":1000,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":3,"input_sequence_hashes":[11,22]}}}}}}"#
588 )
589 .unwrap();
590
591 let loaded = load_request_trace_records(&[file.path().to_path_buf()]).unwrap();
592 assert_eq!(loaded.requests.len(), 1);
593 assert_eq!(loaded.requests[0].request.request_id, "req-1");
594 }
595
596 #[test]
597 fn rejects_unknown_trace_schema() {
598 let mut file = NamedTempFile::new().unwrap();
599 writeln!(
600 file,
601 r#"{{"schema":"dynamo.request.trace.v2","event_type":"request_end","event_time_unix_ms":1100,"request":{{"request_id":"req-1","request_received_ms":1000,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":2,"input_sequence_hashes":[11]}}}}}}"#
602 )
603 .unwrap();
604
605 let error = load_request_trace_records(&[file.path().to_path_buf()]).unwrap_err();
606 assert!(error.to_string().contains("failed to parse"));
607 assert!(format!("{error:#}").contains("unknown variant `dynamo.request.trace.v2`"));
608 }
609
610 #[test]
611 fn request_trace_requires_arrival_time_and_output_length() {
612 let mut file = NamedTempFile::new().unwrap();
613 writeln!(
614 file,
615 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"request":{{"request_id":"req-1","replay":{{"trace_block_size":2,"input_length":2,"input_sequence_hashes":[11]}}}}}}"#
616 )
617 .unwrap();
618
619 let error = load_request_trace_records(&[file.path().to_path_buf()]).unwrap_err();
620 assert!(format!("{error:#}").contains("request trace is missing request_received_ms"));
621 }
622
623 #[test]
624 fn rejects_duplicate_request_ids() {
625 let mut file = NamedTempFile::new().unwrap();
626 for _ in 0..2 {
627 writeln!(
628 file,
629 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"request":{{"request_id":"req-1","request_received_ms":1000,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":2,"input_sequence_hashes":[11]}}}}}}"#
630 )
631 .unwrap();
632 }
633
634 let error = load_request_trace_records(&[file.path().to_path_buf()]).unwrap_err();
635 assert!(error.to_string().contains("duplicate request_id req-1"));
636 }
637
638 #[test]
639 fn rejects_extra_replay_hashes() {
640 let mut file = NamedTempFile::new().unwrap();
641 writeln!(
642 file,
643 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"request":{{"request_id":"req-1","request_received_ms":1000,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":2,"input_sequence_hashes":[11,22]}}}}}}"#
644 )
645 .unwrap();
646
647 let error = load_request_trace_records(&[file.path().to_path_buf()]).unwrap_err();
648 assert!(format!("{error:#}").contains("requires exactly 1 replay hashes, got 2"));
649 }
650
651 #[test]
652 fn loads_agentic_request_trace_rows_and_tool_events() {
653 let mut file = NamedTempFile::new().unwrap();
654 writeln!(
655 file,
656 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"event_source":"dynamo","agent_context":{{"session_id":"root"}},"request":{{"request_id":"req-1","model":"test","request_received_ms":1000,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":3,"input_sequence_hashes":[11,22]}}}}}}"#
657 )
658 .unwrap();
659 writeln!(
660 file,
661 r#"{{"schema":"dynamo.request.trace.v1","event_type":"tool_end","event_time_unix_ms":1200,"event_source":"harness","agent_context":{{"session_id":"root"}},"tool":{{"tool_call_id":"tool-1","tool_class":"search","claude":{{"source_request_id":"req-1","consumer_request_id":"req-2","child_session_id":"child","execution_mode":"background"}},"started_at_unix_ms":1110,"ended_at_unix_ms":1200,"status":"succeeded","duration_ms":90}}}}"#
662 )
663 .unwrap();
664
665 let loaded = load_request_trace_records(&[file.path().to_path_buf()]).unwrap();
666 loaded.ensure_agentic_compatible().unwrap();
667 assert_eq!(loaded.requests.len(), 1);
668 assert_eq!(
669 loaded.requests[0]
670 .agent_context
671 .as_ref()
672 .expect("agent context")
673 .session_id,
674 "root"
675 );
676 assert_eq!(loaded.tools.len(), 1);
677 assert_eq!(loaded.tools[0].tool_call_id, "tool-1");
678 assert_eq!(loaded.tools[0].tool_class, "search");
679 let claude = loaded.tools[0].claude.as_ref().expect("Claude metadata");
680 assert_eq!(claude.source_request_id, "req-1");
681 assert_eq!(claude.consumer_request_id.as_deref(), Some("req-2"));
682 assert_eq!(claude.child_session_id.as_deref(), Some("child"));
683 assert_eq!(claude.execution_mode, "background");
684 }
685}