1use crate::coding::common::{
5 anonymized_session_id, canonical_json_string, content_blocks, flatten_block_content_text,
6 object_field, parse_utc_timestamp_ms,
7};
8use crate::coding::replay::prefix_pool_key;
9use crate::coding::tokenizer::TokenizerWorker;
10use anyhow::Result;
11use rustc_hash::FxHashMap;
12use serde_json::{Map, Value, json};
13use std::collections::{BTreeMap, BTreeSet};
14use std::fs::File;
15use std::io::{BufRead, BufReader};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicUsize, Ordering};
18
19#[derive(Clone, Debug)]
20pub struct TraceRecord {
21 pub session_id: String,
22 pub parent_session_id: Option<String>,
23 pub row_type: String,
24 pub timestamp_ms: i64,
25 pub source_order: u64,
26 pub raw: Value,
27}
28
29#[derive(Clone, Debug)]
30struct ConversationEntry {
31 kind: String,
32 rendered: String,
33}
34
35#[derive(Clone, Debug)]
36struct ToolCallSummary {
37 raw_id: Option<String>,
38 name: String,
39 normalized_id: Option<String>,
40 arg_size_chars: usize,
41 started_at_ms: i64,
42}
43
44#[derive(Clone, Debug)]
45struct CachedProgressMetrics {
46 progress_event_count: usize,
47 agent_ids: BTreeSet<String>,
48 assistant_text_blocks: usize,
49 tool_counts: BTreeMap<String, usize>,
50 tool_result_count: usize,
51 tool_error_count: usize,
52 tool_intervals: Vec<(i64, i64)>,
53 first_ts: i64,
54 last_ts: i64,
55}
56
57#[derive(Debug)]
58struct AssistantGroupSummary {
59 entries_by_record: BTreeMap<usize, Vec<ConversationEntry>>,
60 model: String,
61 output_length: usize,
62 assistant_text_blocks: usize,
63 top_level_tool_calls: Vec<ToolCallSummary>,
64 raw_task_tool_ids: Vec<String>,
65 input_length: Option<usize>,
66 cache_read_input_tokens: Option<usize>,
67 cache_creation_input_tokens: Option<usize>,
68 start_ms: i64,
69 end_ms: i64,
70}
71
72#[derive(Clone, Debug)]
73pub struct ToolDraft {
74 pub tool_call_id: String,
75 pub tool_class: String,
76 pub started_at_ms: i64,
77 pub ended_at_ms: i64,
78 pub is_error: bool,
79 pub output_bytes: usize,
80 pub child_session_id: Option<String>,
81 pub consumer_turn_index: Option<usize>,
82 pub execution_mode: String,
83}
84
85#[derive(Clone, Debug)]
86pub struct TurnDraft {
87 pub session_id: String,
88 pub source_request_id: String,
89 pub export_session_id: String,
90 pub export_parent_session_id: Option<String>,
91 pub turn_index: usize,
92 pub model: String,
93 pub input_text: String,
95 pub prefix_pool_key: String,
96 pub output_length: usize,
97 pub observed_input_length: Option<usize>,
98 pub cache_read_input_tokens: Option<usize>,
99 pub cache_creation_input_tokens: Option<usize>,
100 pub request_start_ms: i64,
101 pub assistant_start_ms: i64,
102 pub assistant_end_ms: i64,
103 pub delay_ms: Option<i64>,
104 pub tools: Vec<ToolDraft>,
105 pub sidecar: Value,
106 pub compaction: Option<CompactionMetadata>,
107}
108
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct CompactionMetadata {
111 pub sequence: usize,
112 pub trigger: String,
113 pub pre_tokens: usize,
114 pub post_tokens: usize,
115 pub duration_ms: i64,
116 pub ended_at_ms: i64,
117}
118
119#[derive(Clone, Debug, Default)]
120pub struct SourceRequestExpectation {
121 pub input_length: Option<usize>,
122 pub cache_read_input_tokens: Option<usize>,
123 pub cache_creation_input_tokens: Option<usize>,
124 pub output_length: Option<usize>,
125 pub request_start_ms: i64,
126 pub assistant_end_ms: i64,
127}
128
129#[derive(Clone, Debug, Default)]
130pub struct SourceFidelityOracle {
131 pub requests: BTreeMap<(String, String), SourceRequestExpectation>,
132 pub compactions: BTreeMap<(String, String), CompactionMetadata>,
133 pub tools_by_class: BTreeMap<String, usize>,
134 pub paired_tools: usize,
135 pub tool_errors: usize,
136 pub child_links: usize,
137 pub background_tools: usize,
138 pub background_agents: usize,
139 pub background_completions_missing: usize,
140 pub background_titles: BTreeSet<(String, String)>,
142 pub unmatched_tool_calls: usize,
143 pub unmatched_tool_results: usize,
144}
145
146#[derive(Debug)]
147struct PendingCompaction {
148 metadata: CompactionMetadata,
149}
150
151#[derive(Debug, Default)]
152struct ToolIdNormalizer {
153 raw_to_normalized: FxHashMap<String, String>,
154}
155
156impl ToolIdNormalizer {
157 fn normalize(&mut self, raw_id: Option<&str>) -> Option<String> {
158 let raw_id = raw_id?;
159 if let Some(existing) = self.raw_to_normalized.get(raw_id) {
160 return Some(existing.clone());
161 }
162 let next_id = self.raw_to_normalized.len() + 1;
163 let normalized = format!("tool_{next_id:04}");
164 self.raw_to_normalized
165 .insert(raw_id.to_string(), normalized.clone());
166 Some(normalized)
167 }
168}
169
170#[derive(Debug)]
171pub struct SessionTurnBuilder {
172 session_id: String,
173 export_session_id: String,
174 export_parent_session_id: Option<String>,
175 cwd: String,
176 records: Vec<TraceRecord>,
177 top_level_indices: Vec<usize>,
178 progress_metrics_index: FxHashMap<String, CachedProgressMetrics>,
179 request_index_by_group_key: FxHashMap<String, usize>,
180 request_start_ms_by_group_key: FxHashMap<String, i64>,
181 request_first_source_orders: Vec<u64>,
182 request_received_ms_by_group_key: FxHashMap<String, i64>,
183 group_positions_by_key: FxHashMap<String, Vec<usize>>,
184 consumed_positions: Vec<bool>,
185 top_level_cursor: usize,
186 normalizer: ToolIdNormalizer,
187 conversation_entries: Vec<ConversationEntry>,
188 prompt_text: String,
189 previous_assistant_end_ms: Option<i64>,
190 turn_index: usize,
191 pending_compaction: Option<PendingCompaction>,
192 previous_model: Option<String>,
193 compaction_sequence: usize,
194 preserve_session_ids: bool,
195}
196
197impl SessionTurnBuilder {
198 pub fn new(trace_id: String, records: Vec<TraceRecord>, preserve_session_ids: bool) -> Self {
199 let root_session_id = records
200 .first()
201 .map(|record| record.session_id.clone())
202 .unwrap_or_else(|| trace_id.clone());
203 let is_subagent = trace_id != root_session_id;
204 let parent_session_id = records
205 .iter()
206 .find_map(|record| record.parent_session_id.clone())
207 .unwrap_or_else(|| root_session_id.clone());
208 let export_session_id = if preserve_session_ids {
209 trace_id.clone()
210 } else {
211 anonymized_session_id(&trace_id)
212 };
213 let export_parent_session_id = is_subagent.then(|| {
214 if preserve_session_ids {
215 parent_session_id
216 } else {
217 anonymized_session_id(&parent_session_id)
218 }
219 });
220
221 let cwd = records
222 .iter()
223 .find_map(|record| record.raw.get("cwd").and_then(Value::as_str))
224 .unwrap_or_default()
225 .to_string();
226 let progress_index = build_progress_index(&records);
227 let progress_metrics_index = build_progress_metrics_index(&progress_index, &records);
228 let top_level_indices: Vec<usize> = records
229 .iter()
230 .enumerate()
231 .filter_map(|(index, record)| is_top_level_row(record, is_subagent).then_some(index))
232 .collect();
233 let mut request_index_by_group_key = FxHashMap::default();
234 let mut request_start_ms_by_group_key = FxHashMap::default();
235 let mut request_first_source_orders = Vec::new();
236 for index in &top_level_indices {
237 let record = &records[*index];
238 if record.row_type != "assistant" {
239 continue;
240 }
241 let group_key = assistant_group_key(record);
242 let next_index = request_index_by_group_key.len();
243 request_index_by_group_key
244 .entry(group_key.clone())
245 .or_insert_with(|| {
246 request_first_source_orders.push(record.source_order);
247 next_index
248 });
249 request_start_ms_by_group_key
250 .entry(group_key)
251 .and_modify(|start: &mut i64| *start = (*start).min(record.timestamp_ms))
252 .or_insert(record.timestamp_ms);
253 }
254 let (group_positions_by_key, request_received_ms_by_group_key) =
255 assign_request_groups(&records, &top_level_indices);
256 let consumed_positions = vec![false; top_level_indices.len()];
257
258 Self {
259 session_id: trace_id,
260 export_session_id,
261 export_parent_session_id,
262 cwd,
263 records,
264 top_level_indices,
265 progress_metrics_index,
266 request_index_by_group_key,
267 request_start_ms_by_group_key,
268 request_first_source_orders,
269 request_received_ms_by_group_key,
270 group_positions_by_key,
271 consumed_positions,
272 top_level_cursor: 0,
273 normalizer: ToolIdNormalizer::default(),
274 conversation_entries: Vec::new(),
275 prompt_text: String::new(),
276 previous_assistant_end_ms: None,
277 turn_index: 0,
278 pending_compaction: None,
279 previous_model: None,
280 compaction_sequence: 0,
281 preserve_session_ids,
282 }
283 }
284
285 pub fn requests_are_usage_shaped(&self) -> bool {
287 self.group_positions_by_key.values().all(|positions| {
288 positions.iter().any(|position| {
289 let record = &self.records[self.top_level_indices[*position]];
290 record.row_type == "assistant"
291 && object_field(&record.raw, "message")
292 .and_then(|message| message.get("usage"))
293 .is_some_and(Value::is_object)
294 })
295 })
296 }
297
298 pub fn next_turn(&mut self, tokenizer: &mut impl TokenizerWorker) -> Result<Option<TurnDraft>> {
299 while self.top_level_cursor < self.top_level_indices.len() {
300 if self.consumed_positions[self.top_level_cursor] {
301 self.top_level_cursor += 1;
302 continue;
303 }
304 let record_index = self.top_level_indices[self.top_level_cursor];
305 let record = &self.records[record_index];
306
307 if record.row_type == "system" {
308 if is_compact_boundary(record) {
309 let metadata = compaction_metadata(record, self.compaction_sequence)?;
310 self.compaction_sequence += 1;
311 self.pending_compaction = Some(PendingCompaction { metadata });
312 }
313 self.top_level_cursor += 1;
314 continue;
315 }
316
317 if record.row_type == "user" {
318 if should_skip_user_record(record) {
319 self.top_level_cursor += 1;
320 continue;
321 }
322
323 let message = object_field(&record.raw, "message");
324 let rendered_entries = render_user_entries(message, &mut self.normalizer)?;
325 if is_compact_summary(record) {
326 let summary_text = flatten_block_content_text(
327 message
328 .and_then(|message| message.get("content"))
329 .unwrap_or(&Value::Null),
330 )?;
331 self.replace_conversation_entries(rendered_entries);
332 self.top_level_cursor += 1;
333
334 let Some(pending) = self.pending_compaction.take() else {
335 continue;
336 };
337
338 let output_length = tokenizer.encode(&summary_text)?.len();
339 let source_request_id = format!("compact:{}", pending.metadata.sequence);
340 let mut sidecar = Map::new();
341 sidecar.insert(
342 "session_id".to_string(),
343 Value::String(self.export_session_id.clone()),
344 );
345 if let Some(parent_session_id) = &self.export_parent_session_id {
346 sidecar.insert(
347 "parent_session_id".to_string(),
348 Value::String(parent_session_id.clone()),
349 );
350 }
351 sidecar.insert("turn_index".to_string(), json!(self.turn_index));
352 sidecar.insert(
353 "source_request_id".to_string(),
354 Value::String(source_request_id.clone()),
355 );
356 sidecar.insert("request_kind".to_string(), json!("compaction"));
357 sidecar.insert(
358 "input_fidelity".to_string(),
359 json!("claude_cache_safe_fork"),
360 );
361 sidecar.insert(
362 "replay_hash_fidelity".to_string(),
363 json!("synthetic_usage_shaped"),
364 );
365 sidecar.insert(
366 "compaction".to_string(),
367 compaction_json(&pending.metadata, output_length),
368 );
369 let request_start_ms = pending
370 .metadata
371 .ended_at_ms
372 .saturating_sub(pending.metadata.duration_ms);
373 self.previous_assistant_end_ms = Some(pending.metadata.ended_at_ms);
374 let model = self
375 .previous_model
376 .clone()
377 .unwrap_or_else(|| "unknown".to_string());
378 return Ok(Some(TurnDraft {
379 session_id: self.session_id.clone(),
380 source_request_id,
381 export_session_id: self.export_session_id.clone(),
382 export_parent_session_id: self.export_parent_session_id.clone(),
383 turn_index: self.turn_index,
384 prefix_pool_key: prefix_pool_key("claude", &model, &self.cwd),
385 model,
386 input_text: String::new(),
388 output_length,
389 observed_input_length: Some(pending.metadata.pre_tokens),
390 cache_read_input_tokens: None,
391 cache_creation_input_tokens: None,
392 request_start_ms,
393 assistant_start_ms: pending.metadata.ended_at_ms,
394 assistant_end_ms: pending.metadata.ended_at_ms,
395 delay_ms: None,
396 tools: Vec::new(),
397 sidecar: Value::Object(sidecar),
398 compaction: Some(pending.metadata),
399 }));
400 } else {
401 self.pending_compaction = None;
402 self.extend_conversation_entries(rendered_entries);
403 }
404 self.top_level_cursor += 1;
405 continue;
406 }
407
408 self.pending_compaction = None;
409 let group_key = assistant_group_key(record);
410 let mut group_indices = Vec::new();
413 let mut interleaved_user_indices = Vec::new();
414 for position in self
415 .group_positions_by_key
416 .remove(&group_key)
417 .unwrap_or_default()
418 {
419 self.consumed_positions[position] = true;
420 let index = self.top_level_indices[position];
421 if self.records[index].row_type == "assistant" {
422 group_indices.push(index);
423 } else {
424 interleaved_user_indices.push(index);
425 }
426 }
427 self.top_level_cursor += 1;
428
429 let mut group_summary = summarize_assistant_group(
430 &self.records,
431 &group_indices,
432 &mut self.normalizer,
433 tokenizer,
434 )?;
435 let input_text = if group_summary.input_length.is_some() {
438 String::new()
439 } else {
440 self.prompt_text.clone()
441 };
442 let request_start_ms = self
443 .request_received_ms_by_group_key
444 .get(&group_key)
445 .copied()
446 .unwrap_or(group_summary.start_ms);
447 let tools = pair_tool_results(
448 &group_summary.top_level_tool_calls,
449 &interleaved_user_indices,
450 &self.records,
451 &self.request_index_by_group_key,
452 &self.request_start_ms_by_group_key,
453 &self.request_first_source_orders,
454 self.preserve_session_ids,
455 )?;
456
457 let top_level_tool_names = group_summary
458 .top_level_tool_calls
459 .iter()
460 .map(|tool_call| tool_call.name.clone())
461 .collect::<Vec<_>>();
462 let used_task_tool = top_level_tool_names.iter().any(|name| name == "Task");
463 let top_level_tool_calls = group_summary
464 .top_level_tool_calls
465 .iter()
466 .map(|tool_call| {
467 json!({
468 "name": tool_call.name,
469 "tool_id": tool_call.normalized_id,
470 "arg_size_chars": tool_call.arg_size_chars,
471 })
472 })
473 .collect::<Vec<_>>();
474
475 let mut sidecar = Map::new();
476 sidecar.insert(
477 "session_id".to_string(),
478 Value::String(self.export_session_id.clone()),
479 );
480 if let Some(parent_session_id) = &self.export_parent_session_id {
481 sidecar.insert(
482 "parent_session_id".to_string(),
483 Value::String(parent_session_id.clone()),
484 );
485 }
486 sidecar.insert("turn_index".to_string(), json!(self.turn_index));
487 sidecar.insert(
488 "source_request_id".to_string(),
489 Value::String(group_key.clone()),
490 );
491 sidecar.insert(
492 "num_messages_in_context".to_string(),
493 json!(self.conversation_entries.len()),
494 );
495 sidecar.insert(
496 "context_shape".to_string(),
497 Value::Array(
498 self.conversation_entries
499 .iter()
500 .map(|entry| Value::String(entry.kind.clone()))
501 .collect(),
502 ),
503 );
504 sidecar.insert(
505 "tool_rounds_before_answer".to_string(),
506 json!(count_trailing_tool_results(&self.conversation_entries)),
507 );
508 sidecar.insert("used_task_tool".to_string(), Value::Bool(used_task_tool));
509 sidecar.insert(
510 "assistant_text_blocks".to_string(),
511 json!(group_summary.assistant_text_blocks),
512 );
513 sidecar.insert(
514 "top_level_tool_call_count".to_string(),
515 json!(group_summary.top_level_tool_calls.len()),
516 );
517 sidecar.insert(
518 "top_level_tool_names".to_string(),
519 Value::Array(
520 top_level_tool_names
521 .iter()
522 .cloned()
523 .map(Value::String)
524 .collect(),
525 ),
526 );
527 sidecar.insert(
528 "top_level_tool_calls".to_string(),
529 Value::Array(top_level_tool_calls),
530 );
531 sidecar.insert(
532 "input_fidelity".to_string(),
533 Value::String(
534 if group_summary.input_length.is_some() {
535 "claude_usage_cache_prefix"
536 } else {
537 "rendered_transcript"
538 }
539 .to_string(),
540 ),
541 );
542 sidecar.insert(
543 "replay_hash_fidelity".to_string(),
544 Value::String(
545 if group_summary.input_length.is_some() {
546 "synthetic_usage_shaped"
547 } else {
548 "rendered_transcript"
549 }
550 .to_string(),
551 ),
552 );
553 if let Some(input_length) = group_summary.input_length {
554 sidecar.insert("observed_input_tokens".to_string(), json!(input_length));
555 }
556 if let Some(cache_read) = group_summary.cache_read_input_tokens {
557 sidecar.insert(
558 "observed_cache_read_input_tokens".to_string(),
559 json!(cache_read),
560 );
561 }
562 if let Some(cache_creation) = group_summary.cache_creation_input_tokens {
563 sidecar.insert(
564 "observed_cache_creation_input_tokens".to_string(),
565 json!(cache_creation),
566 );
567 }
568
569 let progress_metrics = aggregate_progress_metrics(
570 &group_summary.raw_task_tool_ids,
571 &self.progress_metrics_index,
572 &mut self.normalizer,
573 );
574 if let Some(progress_map) = progress_metrics.as_object() {
575 for (key, value) in progress_map {
576 sidecar.insert(key.clone(), value.clone());
577 }
578 }
579
580 let model = group_summary.model;
581 self.previous_model = Some(model.clone());
582 let turn = TurnDraft {
583 session_id: self.session_id.clone(),
584 source_request_id: group_key,
585 export_session_id: self.export_session_id.clone(),
586 export_parent_session_id: self.export_parent_session_id.clone(),
587 turn_index: self.turn_index,
588 prefix_pool_key: prefix_pool_key("claude", &model, &self.cwd),
589 model,
590 input_text,
591 output_length: group_summary.output_length,
592 observed_input_length: group_summary.input_length,
593 cache_read_input_tokens: group_summary.cache_read_input_tokens,
594 cache_creation_input_tokens: group_summary.cache_creation_input_tokens,
595 request_start_ms,
596 assistant_start_ms: group_summary.start_ms,
597 assistant_end_ms: group_summary.end_ms,
598 delay_ms: self
599 .previous_assistant_end_ms
600 .map(|previous_end| (request_start_ms - previous_end).max(0)),
601 tools,
602 sidecar: Value::Object(sidecar),
603 compaction: None,
604 };
605
606 let mut ordered_indices = group_indices;
607 ordered_indices.extend(interleaved_user_indices.iter().copied());
608 ordered_indices.sort_unstable();
609 let mut ordered_entries = Vec::new();
610 for index in ordered_indices {
611 if let Some(entries) = group_summary.entries_by_record.remove(&index) {
612 ordered_entries.extend(entries);
613 } else {
614 ordered_entries.extend(render_user_entries(
615 object_field(&self.records[index].raw, "message"),
616 &mut self.normalizer,
617 )?);
618 }
619 }
620 self.extend_conversation_entries(ordered_entries);
621 self.previous_assistant_end_ms = Some(group_summary.end_ms);
622 self.turn_index += 1;
623 return Ok(Some(turn));
624 }
625
626 Ok(None)
627 }
628
629 fn replace_conversation_entries(&mut self, entries: Vec<ConversationEntry>) {
630 self.prompt_text = render_entry_buffer(&entries);
631 self.conversation_entries = entries;
632 }
633
634 fn extend_conversation_entries(&mut self, entries: Vec<ConversationEntry>) {
635 append_rendered_entries(&mut self.prompt_text, &entries);
636 self.conversation_entries.extend(entries);
637 }
638}
639
640#[derive(Debug, Default)]
646pub struct TraceIndex {
647 files: Vec<IndexedFile>,
648 sessions: FxHashMap<String, IndexedSession>,
649 session_order: Vec<String>,
651 parent_by_session: FxHashMap<String, String>,
652}
653
654#[derive(Debug)]
655struct IndexedFile {
656 path: PathBuf,
657 first_source_order: u64,
658 non_empty_lines: u64,
659}
660
661#[derive(Debug, Default)]
662struct IndexedSession {
663 source_orders: Vec<u64>,
665 files: Vec<usize>,
667 request_start_lower_bound_ms: Option<i64>,
668 child_session_ids: Vec<String>,
670}
671
672struct ScannedRow {
674 line_order: u64,
675 trace_id: String,
676 request_start_bound_ms: Option<i64>,
677 child_session_id: Option<String>,
678}
679
680struct ScannedFile {
681 non_empty_lines: u64,
682 rows: Vec<ScannedRow>,
683}
684
685impl TraceIndex {
686 pub fn build(trace_files: &[PathBuf], workers: usize) -> Result<Self> {
688 let next = AtomicUsize::new(0);
689 let mut scans = std::thread::scope(|scope| {
690 let handles = (0..workers.clamp(1, trace_files.len().max(1)))
691 .map(|_| {
692 scope.spawn(|| {
693 let mut scans = Vec::new();
694 while let Some(path) = trace_files.get(next.fetch_add(1, Ordering::Relaxed))
695 {
696 scans.push((path, scan_trace_file(path)));
697 }
698 scans
699 })
700 })
701 .collect::<Vec<_>>();
702 handles
703 .into_iter()
704 .flat_map(|handle| handle.join().expect("trace scanner panicked"))
705 .collect::<Vec<_>>()
706 });
707 let position = trace_files
708 .iter()
709 .enumerate()
710 .map(|(index, path)| (path, index))
711 .collect::<FxHashMap<_, _>>();
712 scans.sort_by_key(|(path, _)| position[path]);
713
714 let mut index = Self::default();
715 let mut first_source_order = 0_u64;
716 for (path, scan) in scans {
717 let scan = scan?;
718 let file_index = index.files.len();
719 index.files.push(IndexedFile {
720 path: path.clone(),
721 first_source_order,
722 non_empty_lines: scan.non_empty_lines,
723 });
724 for row in scan.rows {
725 if !index.sessions.contains_key(&row.trace_id) {
726 index.session_order.push(row.trace_id.clone());
727 }
728 let session = index.sessions.entry(row.trace_id).or_default();
729 session
730 .source_orders
731 .push(first_source_order + row.line_order);
732 if session.files.last() != Some(&file_index) {
733 session.files.push(file_index);
734 }
735 if let Some(bound) = row.request_start_bound_ms {
736 session.request_start_lower_bound_ms = Some(
737 session
738 .request_start_lower_bound_ms
739 .map_or(bound, |current| current.min(bound)),
740 );
741 }
742 session.child_session_ids.extend(row.child_session_id);
743 }
744 first_source_order += scan.non_empty_lines;
745 }
746 for (parent_session_id, session) in &index.sessions {
748 for child_session_id in &session.child_session_ids {
749 index
750 .parent_by_session
751 .entry(child_session_id.clone())
752 .or_insert_with(|| parent_session_id.clone());
753 }
754 }
755 Ok(index)
756 }
757
758 pub fn is_empty(&self) -> bool {
759 self.sessions.is_empty()
760 }
761
762 pub fn sessions(&self) -> impl Iterator<Item = (&str, Option<i64>)> + '_ {
764 self.session_order.iter().map(|trace_id| {
765 (
766 trace_id.as_str(),
767 self.sessions[trace_id].request_start_lower_bound_ms,
768 )
769 })
770 }
771
772 pub fn load_session(&self, trace_id: &str) -> Result<Vec<TraceRecord>> {
774 let Some(session) = self.sessions.get(trace_id) else {
775 anyhow::bail!("unknown Claude session {trace_id}");
776 };
777 let parent_session_id = self.parent_by_session.get(trace_id);
778 let mut records = Vec::with_capacity(session.source_orders.len());
779 let mut wanted = session.source_orders.iter().copied().peekable();
780 for file_index in &session.files {
781 let file = &self.files[*file_index];
782 let reader = BufReader::new(File::open(&file.path)?);
783 let mut source_order = file.first_source_order;
784 for (line_number, line) in reader.lines().enumerate() {
785 let Some(&next_wanted) = wanted
786 .peek()
787 .filter(|order| **order < file.first_source_order + file.non_empty_lines)
788 else {
789 break;
790 };
791 let line = line?;
792 if line.trim().is_empty() {
793 continue;
794 }
795 if source_order == next_wanted {
796 wanted.next();
797 let payload = parse_trace_line(&line, &file.path, line_number)?;
798 let Some((row_trace_id, mut record)) = trace_record(payload, source_order)
799 else {
800 anyhow::bail!(
801 "{}:{} changed while exporting",
802 file.path.display(),
803 line_number + 1
804 );
805 };
806 if row_trace_id != trace_id {
807 anyhow::bail!(
808 "{}:{} changed while exporting",
809 file.path.display(),
810 line_number + 1
811 );
812 }
813 record.parent_session_id = parent_session_id.cloned();
814 records.push(record);
815 }
816 source_order += 1;
817 }
818 }
819 if wanted.peek().is_some() {
820 anyhow::bail!("Claude session {trace_id} lost rows while exporting");
821 }
822 Ok(records)
823 }
824}
825
826fn scan_trace_file(path: &Path) -> Result<ScannedFile> {
827 let reader = BufReader::new(File::open(path)?);
828 let mut scan = ScannedFile {
829 non_empty_lines: 0,
830 rows: Vec::new(),
831 };
832 for (line_number, line) in reader.lines().enumerate() {
833 let line = line?;
834 if line.trim().is_empty() {
835 continue;
836 }
837 let payload = parse_trace_line(&line, path, line_number)?;
838 let line_order = scan.non_empty_lines;
839 scan.non_empty_lines += 1;
840 let Some((trace_id, record)) = trace_record(payload, line_order) else {
841 continue;
842 };
843 let child_session_id = record
844 .raw
845 .get("toolUseResult")
846 .and_then(Value::as_object)
847 .and_then(|result| result.get("agentId"))
848 .and_then(Value::as_str)
849 .map(str::to_string);
850 scan.rows.push(ScannedRow {
851 line_order,
852 trace_id,
853 request_start_bound_ms: request_start_bound_ms(&record),
854 child_session_id,
855 });
856 }
857 Ok(scan)
858}
859
860fn parse_trace_line(line: &str, path: &Path, line_number: usize) -> Result<Value> {
861 serde_json::from_str(line).map_err(|error| {
862 anyhow::anyhow!(
863 "invalid JSON in {}:{}: {}",
864 path.display(),
865 line_number + 1,
866 error
867 )
868 })
869}
870
871const ROW_FIELDS: &[&str] = &[
873 "agentId",
874 "aiTitle",
875 "compactMetadata",
876 "content",
877 "cwd",
878 "data",
879 "isCompactSummary",
880 "isMeta",
881 "isSidechain",
882 "message",
883 "operation",
884 "parentToolUseID",
885 "requestId",
886 "sessionId",
887 "subtype",
888 "timestamp",
889 "toolUseResult",
890 "type",
891 "uuid",
892];
893const TOOL_USE_RESULT_FIELDS: &[&str] = &["agentId", "backgroundTaskId", "isAsync"];
894
895fn prune_row(payload: &mut Value) {
900 let Some(row) = payload.as_object_mut() else {
901 return;
902 };
903 row.retain(|key, _| ROW_FIELDS.contains(&key.as_str()));
904 match row.get_mut("toolUseResult") {
905 Some(Value::Object(result)) => {
906 result.retain(|key, _| TOOL_USE_RESULT_FIELDS.contains(&key.as_str()));
907 }
908 Some(_) => {
909 row.remove("toolUseResult");
910 }
911 None => {}
912 }
913 let Some(Value::Array(blocks)) = row
914 .get_mut("message")
915 .and_then(|message| message.get_mut("content"))
916 else {
917 return;
918 };
919 for block in blocks {
920 let Some(block) = block.as_object_mut() else {
921 continue;
922 };
923 if matches!(
924 block.get("type").and_then(Value::as_str),
925 Some("thinking" | "redacted_thinking")
926 ) {
927 block.retain(|key, _| key == "type");
928 }
929 }
930}
931
932fn trace_record(mut payload: Value, source_order: u64) -> Option<(String, TraceRecord)> {
934 prune_row(&mut payload);
935 let session_id = payload
936 .get("sessionId")
937 .and_then(Value::as_str)?
938 .to_string();
939 let row_type = payload.get("type").and_then(Value::as_str)?.to_string();
940 let timestamp_ms = match payload.get("timestamp").and_then(Value::as_str) {
941 Some(timestamp) => parse_utc_timestamp_ms(timestamp).ok()?,
942 None if row_type == "ai-title" => 0,
943 None => return None,
944 };
945 let trace_id = payload
946 .get("agentId")
947 .and_then(Value::as_str)
948 .map_or_else(|| session_id.clone(), str::to_string);
949 Some((
950 trace_id,
951 TraceRecord {
952 session_id,
953 parent_session_id: None,
954 row_type,
955 timestamp_ms,
956 source_order,
957 raw: payload,
958 },
959 ))
960}
961
962pub(crate) fn request_start_bound_ms(record: &TraceRecord) -> Option<i64> {
967 if !matches!(record.row_type.as_str(), "user" | "assistant" | "system") {
968 return None;
969 }
970 let compaction_start_ms = is_compact_boundary(record)
971 .then(|| compaction_metadata(record, 0).ok())
972 .flatten()
973 .map(|metadata| metadata.ended_at_ms.saturating_sub(metadata.duration_ms));
974 Some(compaction_start_ms.map_or(record.timestamp_ms, |start| start.min(record.timestamp_ms)))
975}
976
977#[cfg(test)]
978pub(crate) fn load_trace_records(
979 trace_files: &[PathBuf],
980) -> Result<FxHashMap<String, Vec<TraceRecord>>> {
981 let index = TraceIndex::build(trace_files, 2)?;
982 index
983 .sessions()
984 .map(|(trace_id, _)| Ok((trace_id.to_string(), index.load_session(trace_id)?)))
985 .collect()
986}
987
988#[cfg(test)]
989pub(crate) fn build_source_fidelity_oracle(
990 sessions: &FxHashMap<String, Vec<TraceRecord>>,
991) -> Result<SourceFidelityOracle> {
992 let mut oracle = SourceFidelityOracle::default();
993 for (trace_id, records) in sessions {
994 oracle.add_session(trace_id, records)?;
995 }
996 Ok(oracle)
997}
998
999impl SourceFidelityOracle {
1000 pub fn add_session(&mut self, trace_id: &str, records: &[TraceRecord]) -> Result<()> {
1002 let oracle = self;
1003 let trace_id = &trace_id.to_string();
1004 let mut tool_calls: FxHashMap<(String, String), String> = FxHashMap::default();
1005 let mut background_tool_ids = BTreeSet::new();
1006 let background_completions = records
1007 .iter()
1008 .filter_map(|record| {
1009 let content = (record.row_type == "queue-operation"
1010 && record.raw.get("operation").and_then(Value::as_str) == Some("enqueue"))
1011 .then(|| record.raw.get("content").and_then(Value::as_str))
1012 .flatten()?;
1013 Some((
1014 (trace_id.clone(), queued_tool_id(content)?.to_string()),
1015 !content.contains("<status>completed</status>"),
1016 ))
1017 })
1018 .collect::<FxHashMap<_, _>>();
1019
1020 let root_session_id = records
1021 .first()
1022 .map(|record| record.session_id.as_str())
1023 .unwrap_or(trace_id);
1024 let is_subagent = trace_id != root_session_id;
1025 let mut pending_request_start_ms = None;
1026 let mut compaction_sequence = 0;
1027
1028 for record in records {
1029 if record.row_type == "ai-title" {
1030 let title = record
1031 .raw
1032 .get("aiTitle")
1033 .and_then(Value::as_str)
1034 .unwrap_or_default();
1035 oracle
1036 .background_titles
1037 .insert((record.session_id.clone(), title.to_string()));
1038 continue;
1039 }
1040 if !is_top_level_row(record, is_subagent) {
1041 continue;
1042 }
1043 if is_compact_boundary(record) {
1044 let metadata = compaction_metadata(record, compaction_sequence)?;
1045 let source_request_id = format!("compact:{}", metadata.sequence);
1046 oracle
1047 .compactions
1048 .insert((trace_id.clone(), source_request_id), metadata);
1049 compaction_sequence += 1;
1050 continue;
1051 }
1052 if record.row_type == "system" {
1053 continue;
1054 }
1055
1056 if record.row_type == "user" {
1057 if should_skip_user_record(record) {
1058 continue;
1059 }
1060 pending_request_start_ms = Some(record.timestamp_ms);
1061 let message = object_field(&record.raw, "message");
1062 for block in content_blocks(message.and_then(|message| message.get("content"))) {
1063 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
1064 continue;
1065 }
1066 let Some(raw_id) = block.get("tool_use_id").and_then(Value::as_str) else {
1067 continue;
1068 };
1069 let key = (trace_id.clone(), raw_id.to_string());
1070 let Some(tool_class) = tool_calls.remove(&key) else {
1071 oracle.unmatched_tool_results += 1;
1072 continue;
1073 };
1074 oracle.paired_tools += 1;
1075 *oracle.tools_by_class.entry(tool_class).or_insert(0) += 1;
1076 let launch_error = block
1077 .get("is_error")
1078 .and_then(Value::as_bool)
1079 .unwrap_or(false);
1080 let mut is_async = false;
1081 if let Some(result) = record.raw.get("toolUseResult").and_then(Value::as_object)
1082 {
1083 is_async = result
1084 .get("isAsync")
1085 .and_then(Value::as_bool)
1086 .unwrap_or(false)
1087 || result.get("backgroundTaskId").is_some();
1088 if result.get("agentId").and_then(Value::as_str).is_some() {
1089 oracle.child_links += 1;
1090 oracle.background_agents += usize::from(is_async);
1091 }
1092 if is_async {
1093 oracle.background_tools += 1;
1094 background_tool_ids.insert(key);
1095 }
1096 }
1097 oracle.tool_errors += usize::from(if is_async {
1098 background_completions
1099 .get(&(trace_id.clone(), raw_id.to_string()))
1100 .copied()
1101 .unwrap_or(launch_error)
1102 } else {
1103 launch_error
1104 });
1105 }
1106 continue;
1107 }
1108
1109 let group_key = assistant_group_key(record);
1110 let request_key = (trace_id.clone(), group_key);
1111 if !oracle.requests.contains_key(&request_key) {
1112 oracle.requests.insert(
1113 request_key.clone(),
1114 SourceRequestExpectation {
1115 request_start_ms: pending_request_start_ms
1116 .take()
1117 .unwrap_or(record.timestamp_ms),
1118 assistant_end_ms: record.timestamp_ms,
1119 ..Default::default()
1120 },
1121 );
1122 }
1123 let expectation = oracle
1124 .requests
1125 .get_mut(&request_key)
1126 .expect("request expectation was inserted");
1127 expectation.assistant_end_ms = expectation.assistant_end_ms.max(record.timestamp_ms);
1128 let Some(message) = object_field(&record.raw, "message") else {
1129 continue;
1130 };
1131 if let Some(usage) = message.get("usage").and_then(Value::as_object) {
1132 let input_length = [
1133 "input_tokens",
1134 "cache_creation_input_tokens",
1135 "cache_read_input_tokens",
1136 ]
1137 .into_iter()
1138 .filter_map(|key| usage.get(key).and_then(Value::as_u64))
1139 .fold(0_usize, |total, value| total.saturating_add(value as usize));
1140 expectation.input_length = Some(
1141 expectation
1142 .input_length
1143 .unwrap_or_default()
1144 .max(input_length),
1145 );
1146 let cache_read = usage
1147 .get("cache_read_input_tokens")
1148 .and_then(Value::as_u64)
1149 .unwrap_or(0) as usize;
1150 expectation.cache_read_input_tokens = Some(
1151 expectation
1152 .cache_read_input_tokens
1153 .unwrap_or_default()
1154 .max(cache_read),
1155 );
1156 let cache_creation = usage
1157 .get("cache_creation_input_tokens")
1158 .and_then(Value::as_u64)
1159 .unwrap_or(0) as usize;
1160 expectation.cache_creation_input_tokens = Some(
1161 expectation
1162 .cache_creation_input_tokens
1163 .unwrap_or_default()
1164 .max(cache_creation),
1165 );
1166 if let Some(output_length) = usage.get("output_tokens").and_then(Value::as_u64) {
1167 expectation.output_length = Some(
1168 expectation
1169 .output_length
1170 .unwrap_or_default()
1171 .max(output_length as usize),
1172 );
1173 }
1174 }
1175 for block in content_blocks(message.get("content")) {
1176 if block.get("type").and_then(Value::as_str) != Some("tool_use") {
1177 continue;
1178 }
1179 let Some(raw_id) = block.get("id").and_then(Value::as_str) else {
1180 continue;
1181 };
1182 let tool_class = block
1183 .get("name")
1184 .and_then(Value::as_str)
1185 .unwrap_or("unknown")
1186 .to_string();
1187 tool_calls.insert((trace_id.clone(), raw_id.to_string()), tool_class);
1188 }
1189 }
1190 oracle.background_completions_missing += background_tool_ids
1191 .iter()
1192 .filter(|tool_id| !background_completions.contains_key(*tool_id))
1193 .count();
1194 oracle.unmatched_tool_calls += tool_calls.len();
1195 Ok(())
1196 }
1197}
1198
1199fn queued_tool_id(content: &str) -> Option<&str> {
1200 let start = content.find("<tool-use-id>")? + "<tool-use-id>".len();
1201 let end = content[start..].find("</tool-use-id>")? + start;
1202 Some(&content[start..end])
1203}
1204
1205fn is_top_level_row(record: &TraceRecord, is_subagent: bool) -> bool {
1210 let transcript_row = matches!(record.row_type.as_str(), "user" | "assistant" | "system")
1211 && (is_subagent
1212 || !record
1213 .raw
1214 .get("isSidechain")
1215 .and_then(Value::as_bool)
1216 .unwrap_or(false));
1217 let synthetic = record.row_type == "assistant"
1218 && object_field(&record.raw, "message")
1219 .and_then(|message| message.get("model"))
1220 .and_then(Value::as_str)
1221 == Some("<synthetic>");
1222 transcript_row && !synthetic
1223}
1224
1225fn assign_request_groups(
1232 records: &[TraceRecord],
1233 top_level_indices: &[usize],
1234) -> (FxHashMap<String, Vec<usize>>, FxHashMap<String, i64>) {
1235 let mut positions_by_key: FxHashMap<String, Vec<usize>> = FxHashMap::default();
1236 let mut received_ms_by_key = FxHashMap::default();
1237 let mut tool_owners: FxHashMap<String, String> = FxHashMap::default();
1238 let mut segment_key: Option<String> = None;
1239 let mut pending_received_ms = None;
1240 for (position, index) in top_level_indices.iter().enumerate() {
1241 let record = &records[*index];
1242 match record.row_type.as_str() {
1243 "assistant" => {
1244 let key = assistant_group_key(record);
1245 for id in message_block_ids(record, "tool_use", "id") {
1246 tool_owners.insert(id.to_string(), key.clone());
1247 }
1248 received_ms_by_key
1249 .entry(key.clone())
1250 .or_insert_with(|| pending_received_ms.take().unwrap_or(record.timestamp_ms));
1251 positions_by_key
1252 .entry(key.clone())
1253 .or_default()
1254 .push(position);
1255 segment_key = Some(key);
1256 }
1257 "user" if should_skip_user_record(record) => {}
1258 "user" if is_tool_result_user_record(record) => {
1259 pending_received_ms = Some(record.timestamp_ms);
1260 let owner = message_block_ids(record, "tool_result", "tool_use_id")
1261 .find_map(|id| tool_owners.get(id))
1262 .or(segment_key.as_ref());
1263 if let Some(owner) = owner {
1264 positions_by_key
1265 .get_mut(owner)
1266 .expect("tool owner has an assistant row")
1267 .push(position);
1268 }
1269 }
1270 "user" => {
1271 pending_received_ms = Some(record.timestamp_ms);
1272 tool_owners.clear();
1273 segment_key = None;
1274 }
1275 _ if is_compact_boundary(record) => {
1276 tool_owners.clear();
1277 segment_key = None;
1278 }
1279 _ => {}
1280 }
1281 }
1282 (positions_by_key, received_ms_by_key)
1283}
1284
1285fn message_block_ids<'a>(
1286 record: &'a TraceRecord,
1287 block_type: &'a str,
1288 id_field: &'a str,
1289) -> impl Iterator<Item = &'a str> + 'a {
1290 object_field(&record.raw, "message")
1291 .and_then(|message| message.get("content"))
1292 .and_then(Value::as_array)
1293 .into_iter()
1294 .flatten()
1295 .filter(move |block| block.get("type").and_then(Value::as_str) == Some(block_type))
1296 .filter_map(move |block| block.get(id_field).and_then(Value::as_str))
1297}
1298
1299pub(crate) fn assistant_group_key(record: &TraceRecord) -> String {
1300 if let Some(request_id) = record.raw.get("requestId").and_then(Value::as_str) {
1301 return request_id.to_string();
1302 }
1303 if let Some(message_id) = object_field(&record.raw, "message")
1304 .and_then(|message| message.get("id"))
1305 .and_then(Value::as_str)
1306 {
1307 return message_id.to_string();
1308 }
1309 if let Some(uuid) = record.raw.get("uuid").and_then(Value::as_str) {
1310 return uuid.to_string();
1311 }
1312 format!("row-{}", record.source_order)
1313}
1314
1315fn is_compact_boundary(record: &TraceRecord) -> bool {
1316 record.row_type == "system"
1317 && record
1318 .raw
1319 .get("subtype")
1320 .and_then(Value::as_str)
1321 .map(|subtype| subtype == "compact_boundary")
1322 .unwrap_or(false)
1323}
1324
1325fn is_compact_summary(record: &TraceRecord) -> bool {
1326 record.row_type == "user"
1327 && record
1328 .raw
1329 .get("isCompactSummary")
1330 .and_then(Value::as_bool)
1331 .unwrap_or(false)
1332}
1333
1334fn compaction_metadata(record: &TraceRecord, sequence: usize) -> Result<CompactionMetadata> {
1335 let metadata = record
1336 .raw
1337 .get("compactMetadata")
1338 .and_then(Value::as_object)
1339 .ok_or_else(|| anyhow::anyhow!("compact_boundary is missing compactMetadata"))?;
1340 let trigger = metadata
1341 .get("trigger")
1342 .and_then(Value::as_str)
1343 .ok_or_else(|| anyhow::anyhow!("compactMetadata is missing trigger"))?
1344 .to_string();
1345 let pre_tokens = metadata
1346 .get("preTokens")
1347 .and_then(Value::as_u64)
1348 .and_then(|value| usize::try_from(value).ok())
1349 .ok_or_else(|| anyhow::anyhow!("compactMetadata has invalid preTokens"))?;
1350 if pre_tokens == 0 {
1351 anyhow::bail!("compactMetadata preTokens leaves no recoverable prefix");
1352 }
1353 let post_tokens = metadata
1354 .get("postTokens")
1355 .and_then(Value::as_u64)
1356 .and_then(|value| usize::try_from(value).ok())
1357 .ok_or_else(|| anyhow::anyhow!("compactMetadata has invalid postTokens"))?;
1358 let duration_ms = metadata
1359 .get("durationMs")
1360 .and_then(Value::as_u64)
1361 .and_then(|value| i64::try_from(value).ok())
1362 .ok_or_else(|| anyhow::anyhow!("compactMetadata has invalid durationMs"))?;
1363 Ok(CompactionMetadata {
1364 sequence,
1365 trigger,
1366 pre_tokens,
1367 post_tokens,
1368 duration_ms,
1369 ended_at_ms: record.timestamp_ms,
1370 })
1371}
1372
1373fn compaction_json(metadata: &CompactionMetadata, summary_output_tokens: usize) -> Value {
1374 json!({
1375 "trigger": metadata.trigger,
1376 "pre_tokens": metadata.pre_tokens,
1377 "post_tokens": metadata.post_tokens,
1378 "duration_ms": metadata.duration_ms,
1379 "summary_output_tokens": summary_output_tokens,
1380 "cache_fidelity": "recoverable_cache_safe_prefix",
1381 "output_fidelity": "tokenized_compact_summary",
1382 })
1383}
1384
1385fn is_local_command_wrapper_text(text: &str) -> bool {
1386 let stripped = text.trim();
1387 [
1388 "<command-name>",
1389 "<command-message>",
1390 "<command-args>",
1391 "<local-command-caveat>",
1392 "<local-command-stdout>",
1393 "<local-command-stderr>",
1394 ]
1395 .iter()
1396 .any(|prefix| stripped.starts_with(prefix))
1397}
1398
1399fn should_skip_user_record(record: &TraceRecord) -> bool {
1400 if record.row_type != "user" {
1401 return false;
1402 }
1403 if record
1404 .raw
1405 .get("isMeta")
1406 .and_then(Value::as_bool)
1407 .unwrap_or(false)
1408 {
1409 return true;
1410 }
1411
1412 let Some(message) = object_field(&record.raw, "message") else {
1413 return false;
1414 };
1415 let blocks = content_blocks(message.get("content"));
1416 if blocks.is_empty() {
1417 return false;
1418 }
1419 if blocks
1420 .iter()
1421 .any(|block| block.get("type").and_then(Value::as_str) != Some("text"))
1422 {
1423 return false;
1424 }
1425
1426 let texts = blocks
1427 .iter()
1428 .map(|block| {
1429 block
1430 .get("text")
1431 .and_then(Value::as_str)
1432 .unwrap_or_default()
1433 .to_string()
1434 })
1435 .collect::<Vec<_>>();
1436 !texts.is_empty() && texts.iter().all(|text| is_local_command_wrapper_text(text))
1437}
1438
1439fn is_tool_result_user_record(record: &TraceRecord) -> bool {
1440 if record.row_type != "user" {
1441 return false;
1442 }
1443 let Some(message) = object_field(&record.raw, "message") else {
1444 return false;
1445 };
1446 let blocks = content_blocks(message.get("content"));
1447 !blocks.is_empty()
1448 && blocks
1449 .iter()
1450 .all(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
1451}
1452
1453fn pair_tool_results(
1454 calls: &[ToolCallSummary],
1455 user_indices: &[usize],
1456 records: &[TraceRecord],
1457 request_index_by_group_key: &FxHashMap<String, usize>,
1458 request_start_ms_by_group_key: &FxHashMap<String, i64>,
1459 request_first_source_orders: &[u64],
1460 preserve_session_ids: bool,
1461) -> Result<Vec<ToolDraft>> {
1462 let calls_by_id = calls
1463 .iter()
1464 .filter_map(|call| call.raw_id.as_deref().map(|id| (id, call)))
1465 .collect::<FxHashMap<_, _>>();
1466 let mut tools = Vec::new();
1467
1468 for index in user_indices {
1469 let record = &records[*index];
1470 let Some(message) = object_field(&record.raw, "message") else {
1471 continue;
1472 };
1473 for block in content_blocks(message.get("content")) {
1474 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
1475 continue;
1476 }
1477 let Some(raw_id) = block.get("tool_use_id").and_then(Value::as_str) else {
1478 continue;
1479 };
1480 let Some(call) = calls_by_id.get(raw_id) else {
1481 continue;
1482 };
1483 let content = flatten_block_content_text(block.get("content").unwrap_or(&Value::Null))?;
1484 let tool_result = record.raw.get("toolUseResult").and_then(Value::as_object);
1485 let is_async = tool_result
1486 .and_then(|result| result.get("isAsync"))
1487 .and_then(Value::as_bool)
1488 .unwrap_or(false)
1489 || tool_result.is_some_and(|result| result.get("backgroundTaskId").is_some());
1490 let child_session_id = tool_result
1491 .and_then(|result| result.get("agentId"))
1492 .and_then(Value::as_str)
1493 .map(|id| {
1494 if preserve_session_ids {
1495 id.to_string()
1496 } else {
1497 anonymized_session_id(id)
1498 }
1499 });
1500 let launch_error = block
1501 .get("is_error")
1502 .and_then(Value::as_bool)
1503 .unwrap_or(false);
1504 let (ended_at_ms, output_bytes, is_error, consumer_turn_index) = if is_async {
1505 async_tool_completion(
1506 records,
1507 raw_id,
1508 record.timestamp_ms,
1509 request_index_by_group_key,
1510 request_start_ms_by_group_key,
1511 )
1512 .unwrap_or((
1513 record.timestamp_ms,
1514 content.len(),
1515 launch_error,
1516 None,
1517 ))
1518 } else {
1519 (
1520 record.timestamp_ms,
1521 content.len(),
1522 launch_error,
1523 next_consumer_turn(request_first_source_orders, record.source_order),
1524 )
1525 };
1526 tools.push(ToolDraft {
1527 tool_call_id: call
1528 .normalized_id
1529 .clone()
1530 .unwrap_or_else(|| "tool_unknown".to_string()),
1531 tool_class: call.name.clone(),
1532 started_at_ms: call.started_at_ms,
1533 ended_at_ms,
1534 is_error,
1535 output_bytes,
1536 child_session_id,
1537 consumer_turn_index,
1538 execution_mode: if is_async {
1539 "background".to_string()
1540 } else {
1541 "blocking".to_string()
1542 },
1543 });
1544 }
1545 }
1546
1547 Ok(tools)
1548}
1549
1550fn next_consumer_turn(
1554 request_first_source_orders: &[u64],
1555 after_source_order: u64,
1556) -> Option<usize> {
1557 let index = request_first_source_orders.partition_point(|order| *order <= after_source_order);
1558 (index < request_first_source_orders.len()).then_some(index)
1559}
1560
1561fn async_tool_completion(
1562 records: &[TraceRecord],
1563 raw_tool_id: &str,
1564 after_timestamp_ms: i64,
1565 request_index_by_group_key: &FxHashMap<String, usize>,
1566 request_start_ms_by_group_key: &FxHashMap<String, i64>,
1567) -> Option<(i64, usize, bool, Option<usize>)> {
1568 let tool_marker = format!("<tool-use-id>{raw_tool_id}</tool-use-id>");
1569 let completion = records
1570 .iter()
1571 .filter(|record| {
1572 record.timestamp_ms >= after_timestamp_ms
1573 && record.row_type == "queue-operation"
1574 && record.raw.get("operation").and_then(Value::as_str) == Some("enqueue")
1575 && record
1576 .raw
1577 .get("content")
1578 .and_then(Value::as_str)
1579 .is_some_and(|content| content.contains(&tool_marker))
1580 })
1581 .min_by_key(|record| (record.timestamp_ms, record.source_order))?;
1582 let content = completion
1583 .raw
1584 .get("content")
1585 .and_then(Value::as_str)
1586 .unwrap_or_default();
1587 let consumer_turn_index = request_index_by_group_key
1588 .iter()
1589 .filter_map(|(group_key, turn_index)| {
1590 let start_ms = *request_start_ms_by_group_key.get(group_key)?;
1591 (start_ms > completion.timestamp_ms).then_some((start_ms, *turn_index))
1592 })
1593 .min()
1594 .map(|(_, turn_index)| turn_index);
1595 Some((
1596 completion.timestamp_ms,
1597 content.len(),
1598 !content.contains("<status>completed</status>"),
1599 consumer_turn_index,
1600 ))
1601}
1602
1603fn sanitize_structure(value: &Value, normalizer: &mut ToolIdNormalizer) -> Value {
1604 match value {
1605 Value::Object(map) => {
1606 let mut sanitized = Map::new();
1607 for (key, item) in map {
1608 if matches!(
1609 key.as_str(),
1610 "tool_use_id" | "toolUseID" | "parentToolUseID"
1611 ) && let Some(raw_id) = item.as_str()
1612 && let Some(normalized) = normalizer.normalize(Some(raw_id))
1613 {
1614 sanitized.insert(key.clone(), Value::String(normalized));
1615 continue;
1616 }
1617 sanitized.insert(key.clone(), sanitize_structure(item, normalizer));
1618 }
1619 Value::Object(sanitized)
1620 }
1621 Value::Array(items) => Value::Array(
1622 items
1623 .iter()
1624 .map(|item| sanitize_structure(item, normalizer))
1625 .collect(),
1626 ),
1627 _ => value.clone(),
1628 }
1629}
1630
1631fn count_trailing_tool_results(entries: &[ConversationEntry]) -> usize {
1632 entries
1633 .iter()
1634 .rev()
1635 .take_while(|entry| entry.kind == "user_tool_result")
1636 .count()
1637}
1638
1639fn append_rendered_entries(buffer: &mut String, entries: &[ConversationEntry]) {
1640 for entry in entries {
1641 if !buffer.is_empty() {
1642 buffer.push('\n');
1643 }
1644 buffer.push_str(&entry.rendered);
1645 }
1646}
1647
1648fn render_entry_buffer(entries: &[ConversationEntry]) -> String {
1649 let mut buffer = String::new();
1650 append_rendered_entries(&mut buffer, entries);
1651 buffer
1652}
1653
1654fn render_user_entries(
1655 message: Option<&Map<String, Value>>,
1656 normalizer: &mut ToolIdNormalizer,
1657) -> Result<Vec<ConversationEntry>> {
1658 let mut rendered_entries = Vec::new();
1659 let Some(message) = message else {
1660 return Ok(rendered_entries);
1661 };
1662
1663 for block in content_blocks(message.get("content")) {
1664 let block_type = block
1665 .get("type")
1666 .and_then(Value::as_str)
1667 .unwrap_or("unknown");
1668 if matches!(block_type, "thinking" | "redacted_thinking") {
1669 continue;
1670 }
1671 if block_type == "text" {
1672 let text = block
1673 .get("text")
1674 .and_then(Value::as_str)
1675 .unwrap_or_default();
1676 if !text.is_empty() {
1677 rendered_entries.push(ConversationEntry {
1678 kind: "user_text".to_string(),
1679 rendered: format!("[user] {text}"),
1680 });
1681 }
1682 continue;
1683 }
1684 if block_type == "tool_result" {
1685 let normalized_id =
1686 normalizer.normalize(block.get("tool_use_id").and_then(Value::as_str));
1687 let content_text =
1688 flatten_block_content_text(block.get("content").unwrap_or(&Value::Null))?;
1689 let is_error = block
1690 .get("is_error")
1691 .and_then(Value::as_bool)
1692 .unwrap_or(false);
1693 let header = format!(
1694 "[user_tool_result id={} error={}]",
1695 normalized_id.unwrap_or_else(|| "tool_unknown".to_string()),
1696 if is_error { "true" } else { "false" }
1697 );
1698 let rendered = if content_text.is_empty() {
1699 header
1700 } else {
1701 format!("{header} {content_text}")
1702 };
1703 rendered_entries.push(ConversationEntry {
1704 kind: "user_tool_result".to_string(),
1705 rendered,
1706 });
1707 continue;
1708 }
1709
1710 let sanitized = sanitize_structure(&block, normalizer);
1711 rendered_entries.push(ConversationEntry {
1712 kind: "user_block".to_string(),
1713 rendered: format!(
1714 "[user_block type={block_type}] {}",
1715 canonical_json_string(&sanitized)?
1716 ),
1717 });
1718 }
1719
1720 Ok(rendered_entries)
1721}
1722
1723fn summarize_assistant_group(
1724 records: &[TraceRecord],
1725 group_indices: &[usize],
1726 normalizer: &mut ToolIdNormalizer,
1727 tokenizer: &mut impl TokenizerWorker,
1728) -> Result<AssistantGroupSummary> {
1729 let mut entries = Vec::new();
1730 let mut entries_by_record = BTreeMap::new();
1731 let mut tool_calls = Vec::new();
1732 let mut raw_task_tool_ids = Vec::new();
1733 let mut assistant_text_blocks = 0;
1734 let mut output_lengths = Vec::new();
1735 let mut input_lengths = Vec::new();
1736 let mut cache_read_lengths = Vec::new();
1737 let mut cache_creation_lengths = Vec::new();
1738 let mut model = None;
1739
1740 for index in group_indices {
1741 let record = &records[*index];
1742 let mut record_entries = Vec::new();
1743 let Some(message) = object_field(&record.raw, "message") else {
1744 continue;
1745 };
1746 if model.is_none() {
1747 model = message
1748 .get("model")
1749 .and_then(Value::as_str)
1750 .map(str::to_string);
1751 }
1752 if let Some(usage) = message.get("usage").and_then(Value::as_object) {
1753 let input_tokens = usage
1754 .get("input_tokens")
1755 .and_then(Value::as_u64)
1756 .unwrap_or(0) as usize;
1757 let cache_read = usage
1758 .get("cache_read_input_tokens")
1759 .and_then(Value::as_u64)
1760 .unwrap_or(0) as usize;
1761 let cache_creation = usage
1762 .get("cache_creation_input_tokens")
1763 .and_then(Value::as_u64)
1764 .unwrap_or(0) as usize;
1765 input_lengths.push(
1766 input_tokens
1767 .saturating_add(cache_read)
1768 .saturating_add(cache_creation),
1769 );
1770 cache_read_lengths.push(cache_read);
1771 cache_creation_lengths.push(cache_creation);
1772 if let Some(output_tokens) = usage.get("output_tokens").and_then(Value::as_u64) {
1773 output_lengths.push(output_tokens as usize);
1774 }
1775 }
1776
1777 for block in content_blocks(message.get("content")) {
1778 let block_type = block
1779 .get("type")
1780 .and_then(Value::as_str)
1781 .unwrap_or("unknown");
1782 if matches!(block_type, "thinking" | "redacted_thinking") {
1783 continue;
1784 }
1785 if block_type == "text" {
1786 let text = block
1787 .get("text")
1788 .and_then(Value::as_str)
1789 .unwrap_or_default();
1790 if !text.is_empty() {
1791 assistant_text_blocks += 1;
1792 record_entries.push(ConversationEntry {
1793 kind: "assistant_text".to_string(),
1794 rendered: format!("[assistant] {text}"),
1795 });
1796 }
1797 continue;
1798 }
1799 if block_type == "tool_use" {
1800 let raw_id = block.get("id").and_then(Value::as_str);
1801 let normalized_id = normalizer.normalize(raw_id);
1802 let tool_name = block
1803 .get("name")
1804 .and_then(Value::as_str)
1805 .unwrap_or("unknown")
1806 .to_string();
1807 let args_json = canonical_json_string(&sanitize_structure(
1808 block.get("input").unwrap_or(&Value::Null),
1809 normalizer,
1810 ))?;
1811 record_entries.push(ConversationEntry {
1812 kind: "assistant_tool_use".to_string(),
1813 rendered: format!(
1814 "[assistant_tool_use id={} name={} args={}]",
1815 normalized_id
1816 .clone()
1817 .unwrap_or_else(|| "tool_unknown".to_string()),
1818 tool_name,
1819 args_json
1820 ),
1821 });
1822 tool_calls.push(ToolCallSummary {
1823 raw_id: raw_id.map(str::to_string),
1824 name: tool_name.clone(),
1825 normalized_id,
1826 arg_size_chars: args_json.len(),
1827 started_at_ms: record.timestamp_ms,
1828 });
1829 if matches!(tool_name.as_str(), "Agent" | "Task")
1830 && let Some(raw_id) = raw_id
1831 {
1832 raw_task_tool_ids.push(raw_id.to_string());
1833 }
1834 continue;
1835 }
1836
1837 let sanitized = sanitize_structure(&block, normalizer);
1838 record_entries.push(ConversationEntry {
1839 kind: "assistant_block".to_string(),
1840 rendered: format!(
1841 "[assistant_block type={block_type}] {}",
1842 canonical_json_string(&sanitized)?
1843 ),
1844 });
1845 }
1846 entries.extend(record_entries.iter().cloned());
1847 entries_by_record.insert(*index, record_entries);
1848 }
1849
1850 let output_length = if let Some(max_length) = output_lengths.into_iter().max() {
1851 max_length
1852 } else {
1853 let rendered_text = render_entry_buffer(&entries);
1854 tokenizer.encode(&rendered_text)?.len()
1855 };
1856
1857 let start_ms = group_indices
1858 .first()
1859 .map(|index| records[*index].timestamp_ms)
1860 .unwrap_or_default();
1861 let end_ms = group_indices
1862 .last()
1863 .map(|index| records[*index].timestamp_ms)
1864 .unwrap_or(start_ms);
1865
1866 Ok(AssistantGroupSummary {
1867 entries_by_record,
1868 model: model.unwrap_or_else(|| "unknown".to_string()),
1869 output_length,
1870 assistant_text_blocks,
1871 top_level_tool_calls: tool_calls,
1872 raw_task_tool_ids,
1873 input_length: input_lengths.into_iter().max(),
1874 cache_read_input_tokens: cache_read_lengths.into_iter().max(),
1875 cache_creation_input_tokens: cache_creation_lengths.into_iter().max(),
1876 start_ms,
1877 end_ms,
1878 })
1879}
1880
1881fn progress_timestamp_ms(record: &TraceRecord) -> i64 {
1882 record
1883 .raw
1884 .get("data")
1885 .and_then(Value::as_object)
1886 .and_then(|data| data.get("message"))
1887 .and_then(Value::as_object)
1888 .and_then(|message| message.get("timestamp"))
1889 .and_then(Value::as_str)
1890 .and_then(|timestamp| parse_utc_timestamp_ms(timestamp).ok())
1891 .unwrap_or(record.timestamp_ms)
1892}
1893
1894fn build_progress_index(records: &[TraceRecord]) -> FxHashMap<String, Vec<usize>> {
1895 let mut progress_index: FxHashMap<String, Vec<usize>> = FxHashMap::default();
1896 for (index, record) in records.iter().enumerate() {
1897 if !record.row_type.contains("progress") {
1898 continue;
1899 }
1900 let Some(parent_tool_use_id) = record.raw.get("parentToolUseID").and_then(Value::as_str)
1901 else {
1902 continue;
1903 };
1904 progress_index
1905 .entry(parent_tool_use_id.to_string())
1906 .or_default()
1907 .push(index);
1908 }
1909
1910 for indices in progress_index.values_mut() {
1911 indices.sort_by_key(|index| {
1912 let record = &records[*index];
1913 (progress_timestamp_ms(record), record.source_order)
1914 });
1915 }
1916 progress_index
1917}
1918
1919fn build_progress_metrics_index(
1920 progress_index: &FxHashMap<String, Vec<usize>>,
1921 records: &[TraceRecord],
1922) -> FxHashMap<String, CachedProgressMetrics> {
1923 progress_index
1924 .iter()
1925 .map(|(tool_id, indices)| {
1926 (
1927 tool_id.clone(),
1928 summarize_progress_indices(indices, records),
1929 )
1930 })
1931 .collect()
1932}
1933
1934fn aggregate_progress_metrics(
1935 task_tool_ids: &[String],
1936 progress_metrics_index: &FxHashMap<String, CachedProgressMetrics>,
1937 normalizer: &mut ToolIdNormalizer,
1938) -> Value {
1939 let mut selected_metrics = Vec::new();
1940 let mut seen_task_ids = BTreeSet::new();
1941 for task_tool_id in task_tool_ids {
1942 if !seen_task_ids.insert(task_tool_id.as_str()) {
1943 continue;
1944 }
1945 if let Some(metrics) = progress_metrics_index.get(task_tool_id) {
1946 selected_metrics.push(metrics);
1947 }
1948 }
1949
1950 if selected_metrics.is_empty() {
1951 return json!({
1952 "task_parent_tool_ids": task_tool_ids
1953 .iter()
1954 .filter_map(|tool_id| normalizer.normalize(Some(tool_id)))
1955 .collect::<Vec<_>>(),
1956 "nested_progress_event_count": 0,
1957 "nested_agent_count": 0,
1958 "nested_tool_call_count": 0,
1959 "nested_tool_result_count": 0,
1960 "nested_tool_error_count": 0,
1961 "nested_tool_counts": BTreeMap::<String, usize>::new(),
1962 "nested_tool_names": Vec::<String>::new(),
1963 "nested_tool_total_latency_ms": 0,
1964 "nested_tool_max_latency_ms": 0,
1965 "nested_tool_avg_latency_ms": 0,
1966 "nested_tool_max_parallelism": 0,
1967 "nested_assistant_text_blocks": 0,
1968 "task_duration_ms": 0,
1969 });
1970 }
1971
1972 let mut agent_ids = BTreeSet::new();
1973 let mut assistant_text_blocks = 0_usize;
1974 let mut tool_counts: BTreeMap<String, usize> = BTreeMap::new();
1975 let mut tool_intervals = Vec::new();
1976 let mut tool_result_count = 0_usize;
1977 let mut tool_error_count = 0_usize;
1978 let mut first_ts = i64::MAX;
1979 let mut last_ts = i64::MIN;
1980 let mut progress_event_count = 0_usize;
1981
1982 for metrics in selected_metrics {
1983 progress_event_count += metrics.progress_event_count;
1984 assistant_text_blocks += metrics.assistant_text_blocks;
1985 tool_result_count += metrics.tool_result_count;
1986 tool_error_count += metrics.tool_error_count;
1987 first_ts = first_ts.min(metrics.first_ts);
1988 last_ts = last_ts.max(metrics.last_ts);
1989 agent_ids.extend(metrics.agent_ids.iter().cloned());
1990 tool_intervals.extend(metrics.tool_intervals.iter().copied());
1991 for (tool_name, count) in &metrics.tool_counts {
1992 *tool_counts.entry(tool_name.clone()).or_insert(0) += count;
1993 }
1994 }
1995
1996 let total_latency: i64 = tool_intervals
1997 .iter()
1998 .map(|(start, end)| (end - start).max(0))
1999 .sum();
2000 let max_latency = tool_intervals
2001 .iter()
2002 .map(|(start, end)| (end - start).max(0))
2003 .max()
2004 .unwrap_or(0);
2005 let avg_latency = if tool_intervals.is_empty() {
2006 0
2007 } else {
2008 total_latency / tool_intervals.len() as i64
2009 };
2010
2011 let mut parallel_events = Vec::new();
2012 for (start_ms, end_ms) in &tool_intervals {
2013 parallel_events.push((*start_ms, 1_i32));
2014 parallel_events.push((*end_ms, -1_i32));
2015 }
2016 parallel_events.sort_by_key(|(timestamp, delta)| (*timestamp, -delta));
2017 let mut current_parallelism = 0_i32;
2018 let mut max_parallelism = 0_i32;
2019 for (_, delta) in parallel_events {
2020 current_parallelism += delta;
2021 max_parallelism = max_parallelism.max(current_parallelism);
2022 }
2023
2024 json!({
2025 "task_parent_tool_ids": task_tool_ids
2026 .iter()
2027 .filter_map(|tool_id| normalizer.normalize(Some(tool_id)))
2028 .collect::<Vec<_>>(),
2029 "nested_progress_event_count": progress_event_count,
2030 "nested_agent_count": agent_ids.len(),
2031 "nested_tool_call_count": tool_counts.values().sum::<usize>(),
2032 "nested_tool_result_count": tool_result_count,
2033 "nested_tool_error_count": tool_error_count,
2034 "nested_tool_counts": tool_counts,
2035 "nested_tool_names": tool_counts.keys().cloned().collect::<Vec<_>>(),
2036 "nested_tool_total_latency_ms": total_latency,
2037 "nested_tool_max_latency_ms": max_latency,
2038 "nested_tool_avg_latency_ms": avg_latency,
2039 "nested_tool_max_parallelism": max_parallelism,
2040 "nested_assistant_text_blocks": assistant_text_blocks,
2041 "task_duration_ms": (last_ts - first_ts).max(0),
2042 })
2043}
2044
2045fn summarize_progress_indices(indices: &[usize], records: &[TraceRecord]) -> CachedProgressMetrics {
2046 let mut agent_ids = BTreeSet::new();
2047 let mut assistant_text_blocks = 0_usize;
2048 let mut tool_counts: BTreeMap<String, usize> = BTreeMap::new();
2049 let mut tool_start_times: FxHashMap<String, i64> = FxHashMap::default();
2050 let mut tool_intervals = Vec::new();
2051 let mut tool_result_count = 0_usize;
2052 let mut tool_error_count = 0_usize;
2053 let first_ts = progress_timestamp_ms(&records[indices[0]]);
2054 let last_ts = progress_timestamp_ms(&records[*indices.last().unwrap()]);
2055
2056 for index in indices {
2057 let record = &records[*index];
2058 let timestamp_ms = progress_timestamp_ms(record);
2059 let Some(data) = record.raw.get("data").and_then(Value::as_object) else {
2060 continue;
2061 };
2062 if let Some(agent_id) = data.get("agentId").and_then(Value::as_str) {
2063 agent_ids.insert(agent_id.to_string());
2064 }
2065
2066 let Some(nested_message) = data.get("message").and_then(Value::as_object) else {
2067 continue;
2068 };
2069 let nested_type = nested_message
2070 .get("type")
2071 .and_then(Value::as_str)
2072 .unwrap_or_default();
2073 let nested_payload = nested_message.get("message").and_then(Value::as_object);
2074
2075 if nested_type == "assistant" {
2076 let Some(nested_payload) = nested_payload else {
2077 continue;
2078 };
2079 for block in content_blocks(nested_payload.get("content")) {
2080 let block_type = block
2081 .get("type")
2082 .and_then(Value::as_str)
2083 .unwrap_or_default();
2084 if block_type == "text" {
2085 assistant_text_blocks += 1;
2086 continue;
2087 }
2088 if block_type != "tool_use" {
2089 continue;
2090 }
2091 let Some(raw_id) = block.get("id").and_then(Value::as_str) else {
2092 continue;
2093 };
2094 let tool_name = block
2095 .get("name")
2096 .and_then(Value::as_str)
2097 .unwrap_or("unknown")
2098 .to_string();
2099 *tool_counts.entry(tool_name).or_insert(0) += 1;
2100 tool_start_times.insert(raw_id.to_string(), timestamp_ms);
2101 }
2102 continue;
2103 }
2104
2105 if nested_type != "user" {
2106 continue;
2107 }
2108 let Some(nested_payload) = nested_payload else {
2109 continue;
2110 };
2111 for block in content_blocks(nested_payload.get("content")) {
2112 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
2113 continue;
2114 }
2115 let Some(raw_tool_id) = block.get("tool_use_id").and_then(Value::as_str) else {
2116 continue;
2117 };
2118 if let Some(start_ms) = tool_start_times.remove(raw_tool_id) {
2119 tool_intervals.push((start_ms, timestamp_ms));
2120 }
2121 tool_result_count += 1;
2122 if block
2123 .get("is_error")
2124 .and_then(Value::as_bool)
2125 .unwrap_or(false)
2126 {
2127 tool_error_count += 1;
2128 }
2129 }
2130 }
2131
2132 CachedProgressMetrics {
2133 progress_event_count: indices.len(),
2134 agent_ids,
2135 assistant_text_blocks,
2136 tool_counts,
2137 tool_result_count,
2138 tool_error_count,
2139 tool_intervals,
2140 first_ts,
2141 last_ts,
2142 }
2143}
2144
2145#[cfg(test)]
2146mod tests {
2147 use super::{
2148 SessionTurnBuilder, TraceRecord, build_source_fidelity_oracle, load_trace_records,
2149 };
2150 use crate::coding::common::anonymized_session_id;
2151 use crate::coding::tokenizer::TokenizerWorker;
2152 use anyhow::Result;
2153 use rustc_hash::FxHashMap;
2154 use serde_json::{Value, json};
2155 use tempfile::TempDir;
2156
2157 struct StubTokenizer;
2158
2159 impl TokenizerWorker for StubTokenizer {
2160 fn encode(&mut self, text: &str) -> Result<Vec<u32>> {
2161 Ok(vec![text.len() as u32])
2162 }
2163 }
2164
2165 fn make_record(
2166 row_type: &str,
2167 timestamp_ms: i64,
2168 source_order: u64,
2169 raw: Value,
2170 ) -> TraceRecord {
2171 TraceRecord {
2172 session_id: "session-1".to_string(),
2173 parent_session_id: None,
2174 row_type: row_type.to_string(),
2175 timestamp_ms,
2176 source_order,
2177 raw,
2178 }
2179 }
2180
2181 #[test]
2182 fn groups_interleaved_fragments_and_pairs_tool_results() {
2183 let records = vec![
2184 make_record(
2185 "user",
2186 1_000,
2187 0,
2188 json!({"type":"user","message":{"role":"user","content":"start"}}),
2189 ),
2190 make_record(
2191 "assistant",
2192 1_100,
2193 1,
2194 json!({"type":"assistant","requestId":"req-1","message":{"id":"msg-1","content":[{"type":"text","text":"working"}],"usage":{"input_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"output_tokens":7}}}),
2195 ),
2196 make_record(
2197 "assistant",
2198 1_200,
2199 2,
2200 json!({"type":"assistant","requestId":"req-1","message":{"id":"msg-1","content":[{"type":"tool_use","id":"raw-1","name":"Read","input":{}}],"usage":{"input_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"output_tokens":7}}}),
2201 ),
2202 make_record(
2203 "system",
2204 1_250,
2205 3,
2206 json!({"type":"system","subtype":"turn_duration"}),
2207 ),
2208 make_record(
2209 "user",
2210 1_300,
2211 4,
2212 json!({"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"raw-1","content":"ok"}]}}),
2213 ),
2214 make_record(
2215 "assistant",
2216 1_400,
2217 5,
2218 json!({"type":"assistant","requestId":"req-1","message":{"id":"msg-1","content":[{"type":"tool_use","id":"raw-2","name":"Bash","input":{}}],"usage":{"input_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"output_tokens":7}}}),
2219 ),
2220 make_record(
2221 "user",
2222 1_500,
2223 6,
2224 json!({"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"raw-2","content":"failed","is_error":true}]}}),
2225 ),
2226 make_record(
2227 "assistant",
2228 1_600,
2229 7,
2230 json!({"type":"assistant","requestId":"req-2","message":{"id":"msg-2","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":10,"output_tokens":2}}}),
2231 ),
2232 ];
2233
2234 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2235 let first = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2236 let second = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2237
2238 assert_eq!(first.request_start_ms, 1_000);
2239 assert_eq!(first.assistant_end_ms, 1_400);
2240 assert_eq!(first.observed_input_length, Some(10));
2241 assert_eq!(first.output_length, 7);
2242 assert_eq!(first.tools.len(), 2);
2243 assert_eq!(first.tools[0].tool_class, "Read");
2244 assert_eq!(first.tools[0].started_at_ms, 1_200);
2245 assert_eq!(first.tools[0].ended_at_ms, 1_300);
2246 assert!(first.tools[1].is_error);
2247 assert_eq!(second.request_start_ms, 1_500);
2248 assert!(builder.next_turn(&mut StubTokenizer).unwrap().is_none());
2249 }
2250
2251 #[test]
2252 fn ignored_companion_row_does_not_split_streamed_request() {
2253 let records = vec![
2254 make_record(
2255 "user",
2256 1_000,
2257 0,
2258 json!({"type":"user","message":{"role":"user","content":"start"}}),
2259 ),
2260 make_record(
2261 "assistant",
2262 1_100,
2263 1,
2264 json!({"type":"assistant","requestId":"req-1","message":{"id":"msg-1","content":[{"type":"tool_use","id":"raw-1","name":"Read","input":{}}],"usage":{"input_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"output_tokens":7}}}),
2265 ),
2266 make_record(
2267 "user",
2268 1_200,
2269 2,
2270 json!({"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"raw-1","content":"image"}]}}),
2271 ),
2272 make_record(
2273 "user",
2274 1_190,
2275 3,
2276 json!({"type":"user","isMeta":true,"turnCompanion":true,"message":{"role":"user","content":"[Image: original 10x10]"}}),
2277 ),
2278 make_record(
2279 "assistant",
2280 1_300,
2281 4,
2282 json!({"type":"assistant","requestId":"req-1","message":{"id":"msg-1","content":[{"type":"tool_use","id":"raw-2","name":"Bash","input":{}}],"usage":{"input_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"output_tokens":7}}}),
2283 ),
2284 make_record(
2285 "user",
2286 1_400,
2287 5,
2288 json!({"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"raw-2","content":"ok"}]}}),
2289 ),
2290 make_record(
2291 "assistant",
2292 1_500,
2293 6,
2294 json!({"type":"assistant","requestId":"req-2","message":{"id":"msg-2","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":1,"cache_creation_input_tokens":0,"cache_read_input_tokens":10,"output_tokens":2}}}),
2295 ),
2296 ];
2297 let mut sessions = FxHashMap::default();
2298 sessions.insert("session-1".to_string(), records.clone());
2299 let oracle = build_source_fidelity_oracle(&sessions).unwrap();
2300
2301 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2302 let first = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2303 let second = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2304
2305 let expected =
2306 &oracle.requests[&("session-1".to_string(), first.source_request_id.clone())];
2307 assert_eq!(first.assistant_end_ms, expected.assistant_end_ms);
2308 assert_eq!(first.assistant_end_ms, 1_300);
2309 assert_eq!(first.tools.len(), 2);
2310 assert_eq!(second.request_start_ms, 1_400);
2311 assert!(builder.next_turn(&mut StubTokenizer).unwrap().is_none());
2312 }
2313
2314 #[test]
2315 fn synthetic_error_rows_are_not_requests() {
2316 let records = vec![
2317 make_record(
2318 "user",
2319 1_000,
2320 0,
2321 json!({"type":"user","message":{"role":"user","content":"start"}}),
2322 ),
2323 make_record(
2324 "assistant",
2325 1_100,
2326 1,
2327 json!({"type":"assistant","requestId":"synthetic","isApiErrorMessage":true,"message":{"id":"msg-0","model":"<synthetic>","content":[{"type":"text","text":"API Error"}],"usage":{"input_tokens":0,"output_tokens":0}}}),
2328 ),
2329 make_record(
2330 "assistant",
2331 1_500,
2332 2,
2333 json!({"type":"assistant","requestId":"req-1","message":{"id":"msg-1","model":"claude","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"output_tokens":7}}}),
2334 ),
2335 ];
2336 let mut sessions = FxHashMap::default();
2337 sessions.insert("session-1".to_string(), records.clone());
2338 let oracle = build_source_fidelity_oracle(&sessions).unwrap();
2339 assert_eq!(oracle.requests.len(), 1);
2340
2341 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2342 let turn = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2343 assert_eq!(turn.source_request_id, "req-1");
2344 assert_eq!(turn.request_start_ms, 1_000);
2345 assert!(builder.next_turn(&mut StubTokenizer).unwrap().is_none());
2346 }
2347
2348 #[test]
2349 fn interleaved_concurrent_requests_match_fidelity_oracle() {
2350 let assistant = |timestamp_ms, order, request: &str, content: Value| {
2351 make_record(
2352 "assistant",
2353 timestamp_ms,
2354 order,
2355 json!({"type":"assistant","requestId":request,"message":{"id":format!("msg-{request}"),"content":[content],"usage":{"input_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"output_tokens":7}}}),
2356 )
2357 };
2358 let tool_use = |id: &str| json!({"type":"tool_use","id":id,"name":"Read","input":{}});
2359 let tool_result = |timestamp_ms, order, id: &str| {
2360 make_record(
2361 "user",
2362 timestamp_ms,
2363 order,
2364 json!({"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":id,"content":"ok"}]}}),
2365 )
2366 };
2367 let records = vec![
2368 make_record(
2369 "user",
2370 1_000,
2371 0,
2372 json!({"type":"user","message":{"role":"user","content":"start"}}),
2373 ),
2374 assistant(1_100, 1, "req-a", json!({"type":"text","text":"a"})),
2375 assistant(1_200, 2, "req-b", json!({"type":"text","text":"b"})),
2376 assistant(1_300, 3, "req-a", tool_use("raw-a")),
2377 assistant(1_310, 4, "req-b", tool_use("raw-b1")),
2378 tool_result(1_320, 5, "raw-b1"),
2379 tool_result(1_400, 6, "raw-a"),
2380 assistant(1_500, 7, "req-b", tool_use("raw-b2")),
2381 tool_result(1_600, 8, "raw-b2"),
2382 assistant(1_700, 9, "req-c", json!({"type":"text","text":"done"})),
2383 ];
2384 let mut sessions = FxHashMap::default();
2385 sessions.insert("session-1".to_string(), records.clone());
2386 let oracle = build_source_fidelity_oracle(&sessions).unwrap();
2387
2388 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2389 let mut turns = Vec::new();
2390 while let Some(turn) = builder.next_turn(&mut StubTokenizer).unwrap() {
2391 turns.push(turn);
2392 }
2393
2394 let requests = turns
2395 .iter()
2396 .map(|turn| turn.source_request_id.as_str())
2397 .collect::<Vec<_>>();
2398 assert_eq!(requests, ["req-a", "req-b", "req-c"]);
2399 for turn in &turns {
2400 let expected =
2401 &oracle.requests[&("session-1".to_string(), turn.source_request_id.clone())];
2402 assert_eq!(
2403 (turn.request_start_ms, turn.assistant_end_ms),
2404 (expected.request_start_ms, expected.assistant_end_ms),
2405 "{}",
2406 turn.source_request_id
2407 );
2408 }
2409 let tool_ends = |turn: &super::TurnDraft| {
2410 turn.tools
2411 .iter()
2412 .map(|tool| tool.ended_at_ms)
2413 .collect::<Vec<_>>()
2414 };
2415 assert_eq!(tool_ends(&turns[0]), [1_400]);
2416 assert_eq!(tool_ends(&turns[1]), [1_320, 1_600]);
2417 assert!(
2419 turns[..2]
2420 .iter()
2421 .flat_map(|turn| &turn.tools)
2422 .all(|tool| tool.consumer_turn_index == Some(2))
2423 );
2424 assert_eq!(turns[2].request_start_ms, 1_600);
2425 assert_eq!(oracle.paired_tools, 3);
2426 }
2427
2428 #[test]
2429 fn loader_preserves_source_order_for_compaction_markers() {
2430 let temp = TempDir::new().unwrap();
2431 let trace = temp.path().join("session.jsonl");
2432 std::fs::write(
2433 &trace,
2434 concat!(
2435 "{\"type\":\"system\",\"subtype\":\"compact_boundary\",\"sessionId\":\"session-1\",\"timestamp\":\"2026-01-01T00:00:00.002Z\",\"compactMetadata\":{\"trigger\":\"manual\",\"preTokens\":10,\"postTokens\":3,\"durationMs\":1}}\n",
2436 "{\"type\":\"user\",\"isCompactSummary\":true,\"sessionId\":\"session-1\",\"timestamp\":\"2026-01-01T00:00:00.001Z\",\"message\":{\"content\":\"summary\"}}\n",
2437 "{\"type\":\"assistant\",\"sessionId\":\"session-1\",\"timestamp\":\"2026-01-01T00:00:00.003Z\",\"message\":{\"id\":\"msg-1\",\"content\":[{\"type\":\"text\",\"text\":\"done\"}]}}\n"
2438 ),
2439 )
2440 .unwrap();
2441
2442 let sessions = load_trace_records(&[trace]).unwrap();
2443 let records = sessions.get("session-1").unwrap();
2444 assert_eq!(records[0].row_type, "system");
2445 assert_eq!(records[1].row_type, "user");
2446
2447 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records.clone(), true);
2448 let compaction = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2449 assert!(compaction.compaction.is_some());
2450 let turn = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2451 assert_eq!(turn.input_text, "[user] summary");
2452 }
2453
2454 #[test]
2455 fn loader_infers_immediate_parent_from_agent_result() {
2456 let temp = TempDir::new().unwrap();
2457 let parent = temp.path().join("parent.jsonl");
2458 let child = temp.path().join("child.jsonl");
2459 std::fs::write(
2460 &parent,
2461 "{\"type\":\"user\",\"sessionId\":\"root\",\"agentId\":\"parent\",\"timestamp\":\"2026-01-01T00:00:00.001Z\",\"toolUseResult\":{\"agentId\":\"child\"},\"message\":{\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"agent-call\",\"content\":\"done\"}]}}\n",
2462 )
2463 .unwrap();
2464 std::fs::write(
2465 &child,
2466 "{\"type\":\"assistant\",\"sessionId\":\"root\",\"agentId\":\"child\",\"timestamp\":\"2026-01-01T00:00:00.002Z\",\"message\":{\"id\":\"msg-1\",\"content\":[{\"type\":\"text\",\"text\":\"done\"}],\"usage\":{\"output_tokens\":1}}}\n",
2467 )
2468 .unwrap();
2469
2470 let sessions = load_trace_records(&[parent, child]).unwrap();
2471 assert_eq!(
2472 sessions["child"][0].parent_session_id.as_deref(),
2473 Some("parent")
2474 );
2475 }
2476
2477 #[test]
2478 fn fidelity_oracle_ignores_root_sidechain_compaction() {
2479 let mut sessions = FxHashMap::default();
2480 sessions.insert(
2481 "session-1".to_string(),
2482 vec![make_record(
2483 "system",
2484 1_000,
2485 0,
2486 json!({"type":"system","subtype":"compact_boundary","isSidechain":true,"compactMetadata":{"trigger":"manual","preTokens":10,"postTokens":3,"durationMs":500}}),
2487 )],
2488 );
2489
2490 assert!(
2491 build_source_fidelity_oracle(&sessions)
2492 .unwrap()
2493 .compactions
2494 .is_empty()
2495 );
2496 }
2497
2498 #[test]
2499 fn compact_boundary_restarts_transcript_from_summary() {
2500 let records = vec![
2501 make_record(
2502 "user",
2503 1_000,
2504 0,
2505 json!({"type":"user","message":{"role":"user","content":"before compact"}}),
2506 ),
2507 make_record(
2508 "assistant",
2509 2_000,
2510 1,
2511 json!({"type":"assistant","message":{"id":"assistant-1","content":[{"type":"text","text":"first answer"}]}}),
2512 ),
2513 make_record(
2514 "system",
2515 3_000,
2516 2,
2517 json!({"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":10,"postTokens":3,"durationMs":500}}),
2518 ),
2519 make_record(
2520 "user",
2521 3_001,
2522 3,
2523 json!({"type":"user","isCompactSummary":true,"message":{"role":"user","content":"compacted summary"}}),
2524 ),
2525 make_record(
2526 "assistant",
2527 4_000,
2528 4,
2529 json!({"type":"assistant","message":{"id":"assistant-2","content":[{"type":"text","text":"second answer"}]}}),
2530 ),
2531 ];
2532
2533 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2534 let mut tokenizer = StubTokenizer;
2535 let mut turns = Vec::new();
2536 while let Some(turn) = builder.next_turn(&mut tokenizer).unwrap() {
2537 turns.push(turn);
2538 }
2539
2540 assert_eq!(
2541 turns
2542 .iter()
2543 .filter(|turn| turn.compaction.is_none())
2544 .map(|turn| turn.input_text.as_str())
2545 .collect::<Vec<_>>(),
2546 vec!["[user] before compact", "[user] compacted summary"]
2547 );
2548 assert_eq!(
2549 turns
2550 .iter()
2551 .filter(|turn| turn.compaction.is_some())
2552 .count(),
2553 1
2554 );
2555 }
2556
2557 #[test]
2558 fn compact_boundary_survives_ignored_rows_around_summary() {
2559 let records = vec![
2560 make_record(
2561 "user",
2562 1_000,
2563 0,
2564 json!({"type":"user","message":{"role":"user","content":"before compact"}}),
2565 ),
2566 make_record(
2567 "assistant",
2568 2_000,
2569 1,
2570 json!({"type":"assistant","message":{"id":"assistant-1","content":[{"type":"text","text":"first answer"}]}}),
2571 ),
2572 make_record(
2573 "system",
2574 3_000,
2575 2,
2576 json!({"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":10,"postTokens":3,"durationMs":500}}),
2577 ),
2578 make_record(
2579 "system",
2580 3_001,
2581 3,
2582 json!({"type":"system","subtype":"turn_duration"}),
2583 ),
2584 make_record(
2585 "user",
2586 3_002,
2587 4,
2588 json!({"type":"user","isMeta":true,"message":{"role":"user","content":"<local-command-caveat>ignore me</local-command-caveat>"}}),
2589 ),
2590 make_record(
2591 "user",
2592 3_003,
2593 5,
2594 json!({"type":"user","isCompactSummary":true,"message":{"role":"user","content":"compacted summary"}}),
2595 ),
2596 make_record(
2597 "user",
2598 3_004,
2599 6,
2600 json!({"type":"user","isMeta":true,"message":{"role":"user","content":"<local-command-caveat>ignore me</local-command-caveat>"}}),
2601 ),
2602 make_record(
2603 "user",
2604 3_005,
2605 7,
2606 json!({"type":"user","message":{"role":"user","content":"<command-name>/compact</command-name>\n<command-message>compact</command-message>"}}),
2607 ),
2608 make_record(
2609 "user",
2610 3_006,
2611 8,
2612 json!({"type":"user","message":{"role":"user","content":"<local-command-stdout>Compacted</local-command-stdout>"}}),
2613 ),
2614 make_record(
2615 "assistant",
2616 4_000,
2617 9,
2618 json!({"type":"assistant","message":{"id":"assistant-2","content":[{"type":"text","text":"second answer"}]}}),
2619 ),
2620 ];
2621
2622 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2623 let mut tokenizer = StubTokenizer;
2624 let mut turns = Vec::new();
2625 while let Some(turn) = builder.next_turn(&mut tokenizer).unwrap() {
2626 turns.push(turn);
2627 }
2628
2629 assert_eq!(
2630 turns
2631 .iter()
2632 .filter(|turn| turn.compaction.is_none())
2633 .map(|turn| turn.input_text.as_str())
2634 .collect::<Vec<_>>(),
2635 vec!["[user] before compact", "[user] compacted summary"]
2636 );
2637 }
2638
2639 #[test]
2640 fn orphan_compact_summary_still_replaces_transcript() {
2641 let records = vec![
2642 make_record(
2643 "user",
2644 1_000,
2645 0,
2646 json!({"type":"user","message":{"role":"user","content":"old prompt"}}),
2647 ),
2648 make_record(
2649 "assistant",
2650 2_000,
2651 1,
2652 json!({"type":"assistant","message":{"id":"assistant-1","content":[{"type":"text","text":"old answer"}]}}),
2653 ),
2654 make_record(
2655 "user",
2656 3_000,
2657 2,
2658 json!({"type":"user","isCompactSummary":true,"message":{"role":"user","content":"summary only"}}),
2659 ),
2660 make_record(
2661 "assistant",
2662 4_000,
2663 3,
2664 json!({"type":"assistant","message":{"id":"assistant-2","content":[{"type":"text","text":"new answer"}]}}),
2665 ),
2666 ];
2667
2668 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2669 let mut tokenizer = StubTokenizer;
2670 assert_eq!(
2671 builder
2672 .next_turn(&mut tokenizer)
2673 .unwrap()
2674 .unwrap()
2675 .input_text,
2676 "[user] old prompt"
2677 );
2678 assert_eq!(
2679 builder
2680 .next_turn(&mut tokenizer)
2681 .unwrap()
2682 .unwrap()
2683 .input_text,
2684 "[user] summary only"
2685 );
2686 }
2687
2688 #[test]
2689 fn background_agent_joins_only_after_completion_notification() {
2690 let records = vec![
2691 make_record(
2692 "user",
2693 1_000,
2694 0,
2695 json!({"type":"user","message":{"role":"user","content":"start agent"}}),
2696 ),
2697 make_record(
2698 "assistant",
2699 1_100,
2700 1,
2701 json!({"type":"assistant","requestId":"req-1","message":{"id":"msg-1","content":[{"type":"tool_use","id":"agent-call","name":"Agent","input":{"run_in_background":true}}],"usage":{"output_tokens":2}}}),
2702 ),
2703 make_record(
2704 "user",
2705 1_150,
2706 2,
2707 json!({"type":"user","toolUseResult":{"isAsync":true,"agentId":"child-agent","status":"async_launched"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"agent-call","content":"launched"}]}}),
2708 ),
2709 make_record(
2710 "user",
2711 1_300,
2712 3,
2713 json!({"type":"user","message":{"role":"user","content":"keep working"}}),
2714 ),
2715 make_record(
2716 "assistant",
2717 1_400,
2718 4,
2719 json!({"type":"assistant","requestId":"req-2","message":{"id":"msg-2","content":[{"type":"text","text":"still working"}],"usage":{"output_tokens":2}}}),
2720 ),
2721 make_record(
2722 "queue-operation",
2723 1_800,
2724 5,
2725 json!({"type":"queue-operation","operation":"enqueue","content":"<tool-use-id>agent-call</tool-use-id><status>completed</status>done"}),
2726 ),
2727 make_record(
2728 "assistant",
2729 1_810,
2730 6,
2731 json!({"type":"assistant","requestId":"req-2","message":{"id":"msg-2","content":[{"type":"text","text":"late fragment"}],"usage":{"output_tokens":2}}}),
2732 ),
2733 make_record(
2734 "user",
2735 1_850,
2736 7,
2737 json!({"type":"user","message":{"role":"user","content":"use result"}}),
2738 ),
2739 make_record(
2740 "assistant",
2741 1_900,
2742 8,
2743 json!({"type":"assistant","requestId":"req-3","message":{"id":"msg-3","content":[{"type":"text","text":"finished"}],"usage":{"output_tokens":1}}}),
2744 ),
2745 ];
2746
2747 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2748 let turns = std::iter::from_fn(|| builder.next_turn(&mut StubTokenizer).transpose())
2749 .collect::<Result<Vec<_>>>()
2750 .unwrap();
2751
2752 assert_eq!(turns.len(), 3);
2753 assert_eq!(turns[0].tools.len(), 1);
2754 let tool = &turns[0].tools[0];
2755 assert_eq!(tool.ended_at_ms, 1_800);
2756 assert_eq!(tool.consumer_turn_index, Some(2));
2757 assert_eq!(tool.child_session_id.as_deref(), Some("child-agent"));
2758 assert_eq!(tool.execution_mode, "background");
2759 }
2760
2761 #[test]
2762 fn background_bash_uses_terminal_notification_status() {
2763 let records = vec![
2764 make_record(
2765 "user",
2766 1_000,
2767 0,
2768 json!({"type":"user","message":{"role":"user","content":"start command"}}),
2769 ),
2770 make_record(
2771 "assistant",
2772 1_100,
2773 1,
2774 json!({"type":"assistant","requestId":"req-1","message":{"id":"msg-1","content":[{"type":"tool_use","id":"bash-call","name":"Bash","input":{"run_in_background":true}}],"usage":{"output_tokens":2}}}),
2775 ),
2776 make_record(
2777 "user",
2778 1_150,
2779 2,
2780 json!({"type":"user","toolUseResult":{"backgroundTaskId":"task-1"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"bash-call","content":"launched","is_error":false}]}}),
2781 ),
2782 make_record(
2783 "assistant",
2784 1_300,
2785 3,
2786 json!({"type":"assistant","requestId":"req-2","message":{"id":"msg-2","content":[{"type":"text","text":"other work"}],"usage":{"output_tokens":1}}}),
2787 ),
2788 make_record(
2789 "queue-operation",
2790 1_500,
2791 4,
2792 json!({"type":"queue-operation","operation":"enqueue","content":"<task-id>task-1</task-id><tool-use-id>bash-call</tool-use-id><status>failed</status>"}),
2793 ),
2794 make_record(
2795 "user",
2796 1_510,
2797 5,
2798 json!({"type":"user","message":{"role":"user","content":"<tool-use-id>bash-call</tool-use-id><status>failed</status>"}}),
2799 ),
2800 make_record(
2801 "assistant",
2802 1_600,
2803 6,
2804 json!({"type":"assistant","requestId":"req-3","message":{"id":"msg-3","content":[{"type":"text","text":"handled"}],"usage":{"output_tokens":1}}}),
2805 ),
2806 ];
2807 let mut sessions = FxHashMap::default();
2808 sessions.insert("session-1".to_string(), records.clone());
2809 let oracle = build_source_fidelity_oracle(&sessions).unwrap();
2810 assert_eq!(oracle.background_tools, 1);
2811 assert_eq!(oracle.background_agents, 0);
2812 assert_eq!(oracle.background_completions_missing, 0);
2813 assert_eq!(oracle.tool_errors, 1);
2814
2815 let mut builder = SessionTurnBuilder::new("session-1".to_string(), records, true);
2816 let first = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2817 let tool = &first.tools[0];
2818 assert_eq!(tool.ended_at_ms, 1_500);
2819 assert_eq!(tool.consumer_turn_index, Some(2));
2820 assert!(tool.is_error);
2821 assert!(tool.child_session_id.is_none());
2822 assert_eq!(tool.execution_mode, "background");
2823 }
2824
2825 #[test]
2826 fn child_identity_anonymizes_child_and_parent_consistently() {
2827 let records = vec![
2828 make_record(
2829 "user",
2830 1_000,
2831 0,
2832 json!({"type":"user","isSidechain":true,"message":{"role":"user","content":"task"}}),
2833 ),
2834 make_record(
2835 "assistant",
2836 2_000,
2837 1,
2838 json!({"type":"assistant","isSidechain":true,"message":{"id":"child-1","content":[{"type":"text","text":"done"}],"usage":{"output_tokens":1}}}),
2839 ),
2840 ];
2841 let mut builder = SessionTurnBuilder::new("child-agent".to_string(), records, false);
2842
2843 let turn = builder.next_turn(&mut StubTokenizer).unwrap().unwrap();
2844
2845 assert_eq!(turn.export_session_id, anonymized_session_id("child-agent"));
2846 assert_eq!(
2847 turn.export_parent_session_id.as_deref(),
2848 Some(anonymized_session_id("session-1").as_str())
2849 );
2850 }
2851}