Skip to main content

dynamo_bench/coding/claude/
parser.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use 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    /// Rendered transcript, kept only for turns without usage, which replay it as tokens.
94    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    /// Distinct AI-title rows, which lack the timing and usage to replay as requests.
141    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    /// Whether every request carries usage, so no turn needs the rendered transcript.
286    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                        // Compaction replays its recorded input length, never a transcript.
387                        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            // A request owns every row with its key, even when another request streams in
411            // between, plus the tool results assigned to it.
412            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            // Only a turn without usage replays its rendered transcript; skipping the copy for
436            // the rest keeps long sessions from allocating one transcript copy per turn.
437            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/// Locates every session's rows in a set of Claude trace files without holding row content.
641///
642/// The export parses a session's rows only when its merge reaches them, so peak memory follows
643/// the sessions active at one time rather than the whole corpus. `source_order` numbers the
644/// non-empty lines of all files in discovery order.
645#[derive(Debug, Default)]
646pub struct TraceIndex {
647    files: Vec<IndexedFile>,
648    sessions: FxHashMap<String, IndexedSession>,
649    /// Sessions in order of their first row.
650    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    /// Ascending source orders of the session's rows.
664    source_orders: Vec<u64>,
665    /// Ascending indices of the files holding those rows.
666    files: Vec<usize>,
667    request_start_lower_bound_ms: Option<i64>,
668    /// Agents whose completed results this session recorded, in source order.
669    child_session_ids: Vec<String>,
670}
671
672/// The parts of one row the index keeps.
673struct 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    /// Scans `trace_files` on up to `workers` threads.
687    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        // A child recorded by several sessions, as after a resume, keeps the first parent found.
747        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    /// Sessions in order of first appearance, with a lower bound on their request start times.
763    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    /// Parses one session's rows in source order.
773    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
871/// Top-level row fields the export reads. Others, such as attachment payloads, are dropped.
872const 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
895/// Drops the parts of a row the export never reads, which dominate a parsed session's memory.
896///
897/// Tool results keep only their child-agent and background markers, and thinking blocks keep only
898/// their type because transcript rendering skips them.
899fn 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
932/// The session a row belongs to and its record, or `None` for rows the export ignores.
933fn 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
962/// No request built from this row can start earlier.
963///
964/// Requests start at a user, tool-result, or assistant row, except compaction requests, which
965/// start their recorded duration before the compact boundary.
966pub(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    /// Adds the expectations derived from one session's source rows.
1001    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
1205/// Whether a row belongs to the session's own transcript.
1206///
1207/// Claude Code writes `<synthetic>` assistant rows for API errors and interruptions. They carry
1208/// zero usage and never reach the model, so they are neither requests nor context.
1209fn 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
1225/// Assigns top-level positions to requests and records when each request was received.
1226///
1227/// A request owns all assistant rows with its key. A tool result belongs to the request that
1228/// issued it, or to the latest request of its segment when no call matches. Prompts and compact
1229/// boundaries start a new segment. A request is received at the latest preceding prompt or tool
1230/// result that an earlier request has not claimed, or at its first row otherwise.
1231fn 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
1550/// Returns the first request whose first row follows a tool result.
1551///
1552/// Request indices follow first appearance, so `request_first_source_orders` is ascending.
1553fn 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        // req-b started before req-a's result, so only req-c can consume either result.
2418        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}