1use crate::coding::claude::parser::{
10 SessionTurnBuilder, SourceFidelityOracle, TraceIndex, TraceRecord, TurnDraft,
11 request_start_bound_ms,
12};
13use crate::coding::replay::{
14 AgentContext, HARNESS_EVENT_SOURCE, PrefixPool, REQUEST_TRACE_SCHEMA, ReplayBase, ReplayFields,
15 RequestEndEvent, RequestFields, ToolEvent, ToolFields, TraceLine, synthetic_stream_seed,
16 synthetic_token, usage_shaped_tokens,
17};
18use crate::coding::tokenizer::{
19 LazyTokenizer, TokenizerFactory, TokenizerWorker, last_word_overlap_start,
20};
21use anyhow::{Result, anyhow, bail};
22use crossbeam_channel::{Receiver, Sender, bounded, unbounded};
23use rustc_hash::FxHashMap;
24use serde::Serialize;
25use serde_json::json;
26use std::cmp::Reverse;
27use std::collections::{BTreeMap, BTreeSet, BinaryHeap, VecDeque};
28use std::fs::File;
29use std::io::{BufWriter, Write};
30use std::path::Path;
31use std::thread::{self, JoinHandle};
32
33#[derive(Debug, Clone, Copy)]
34pub struct ExportConfig {
35 pub block_size: usize,
36 pub delta_overlap_words: usize,
37 pub tokenizer_workers: usize,
38}
39
40#[derive(Debug, Clone, Default)]
41pub struct ExportStats {
42 pub row_count: usize,
43 pub tool_row_count: usize,
44 pub sidecar_count: usize,
45 pub max_heap_len: usize,
46 pub fidelity: FidelityReport,
47}
48
49#[derive(Debug, Clone, Default)]
50pub struct FidelityReport {
51 pub requests_verified: usize,
52 pub compactions_verified: usize,
53 pub usage_requests_verified: usize,
54 pub tools_verified: usize,
55 pub child_links_verified: usize,
56 pub background_tools: usize,
57 pub background_agents: usize,
58 pub background_completions_missing: usize,
59 pub background_titles_unreplayable: usize,
60 pub cache_prefix_blocks_verified: usize,
61 pub compaction_prefix_blocks_verified: usize,
62 pub post_compaction_prefix_blocks_verified: usize,
63 pub pooled_prefix_blocks: usize,
64 pub unmatched_tool_calls: usize,
65 pub unmatched_tool_results: usize,
66 pub unresolved_child_sessions: usize,
67}
68
69#[derive(Serialize)]
75struct ClaudeToolReplayMetadata {
76 source_request_id: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 consumer_request_id: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 child_session_id: Option<String>,
81 execution_mode: String,
82}
83
84impl FidelityReport {
85 pub fn render(&self) -> String {
86 let ordinary_requests = self
87 .requests_verified
88 .saturating_sub(self.compactions_verified);
89 format!(
90 "Fidelity: requests={0}/{0} compactions={1}/{1} usage={2}/{15} tools={3}/{3} child_links={4}/{4} cache_prefix_blocks={5} compaction_prefix_blocks={6} post_compaction_prefix_blocks={7} pooled_prefix_blocks={16}\nBackground: tools={8} agents={9} missing_completions={10} title_requests_unreplayable={11}\nLimitations: synthetic_kv_hashes={0} unmatched_tool_calls={12} unmatched_tool_results={13} unresolved_child_sessions={14}",
91 self.requests_verified,
92 self.compactions_verified,
93 self.usage_requests_verified,
94 self.tools_verified,
95 self.child_links_verified,
96 self.cache_prefix_blocks_verified,
97 self.compaction_prefix_blocks_verified,
98 self.post_compaction_prefix_blocks_verified,
99 self.background_tools,
100 self.background_agents,
101 self.background_completions_missing,
102 self.background_titles_unreplayable,
103 self.unmatched_tool_calls,
104 self.unmatched_tool_results,
105 self.unresolved_child_sessions,
106 ordinary_requests,
107 self.pooled_prefix_blocks,
108 )
109 }
110}
111
112struct FidelityVerifier {
113 oracle: SourceFidelityOracle,
114 seen_requests: BTreeSet<(String, String)>,
115 seen_compactions: BTreeSet<(String, String)>,
116 tools_by_class: BTreeMap<String, usize>,
117 tool_count: usize,
118 tool_errors: usize,
119 child_links: usize,
120 background_tools: usize,
121 background_agents: usize,
122 usage_requests: usize,
123 cache_prefix_blocks_verified: usize,
124 compaction_prefix_blocks_verified: usize,
125 post_compaction_prefix_blocks_verified: usize,
126 next_turn_by_session: FxHashMap<String, usize>,
127 previous_hashes_by_session: FxHashMap<String, Vec<u64>>,
128 previous_input_length_by_session: FxHashMap<String, usize>,
129 previous_was_compaction_by_session: FxHashMap<String, bool>,
130 expected_next_cache_read_by_session: FxHashMap<String, usize>,
131 export_sessions: BTreeSet<String>,
132 causal_references: Vec<(String, usize, String)>,
133 child_session_references: Vec<String>,
134}
135
136#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
137struct HeapEntry {
138 request_start_ms: i64,
139 turn_index: usize,
140 export_session_id: String,
141 session_id: String,
142}
143
144#[derive(Debug)]
145struct OverlapBase {
146 previous_text: String,
147 previous_tokens: Vec<u32>,
148}
149
150#[derive(Debug)]
151struct ReadyTurn {
152 current_text: String,
153 tokens: Vec<u32>,
154}
155
156#[derive(Debug)]
157struct HeadTurn {
158 turn: TurnDraft,
159 sidecar_line: String,
160 turn_key: u64,
161 scheduled: bool,
162 ready: Option<ReadyTurn>,
163}
164
165#[derive(Debug)]
167struct QueuedTurn {
168 turn: TurnDraft,
169 sidecar_line: String,
170}
171
172impl QueuedTurn {
173 fn new(mut turn: TurnDraft) -> Result<Self> {
174 let sidecar_line = serde_json::to_string(&turn.sidecar)?;
175 turn.sidecar = serde_json::Value::Null;
176 if turn.observed_input_length.is_some() {
178 turn.input_text = String::new();
179 }
180 Ok(Self { turn, sidecar_line })
181 }
182}
183
184#[derive(Debug)]
186enum SessionTurns {
187 Built(VecDeque<QueuedTurn>),
189 Lazy(Box<SessionTurnBuilder>),
191}
192
193impl SessionTurns {
194 fn open(mut builder: SessionTurnBuilder, tokenizer: &mut impl TokenizerWorker) -> Result<Self> {
196 if !builder.requests_are_usage_shaped() {
197 return Ok(Self::Lazy(Box::new(builder)));
198 }
199 let mut turns = VecDeque::new();
200 while let Some(turn) = builder.next_turn(tokenizer)? {
201 turns.push_back(QueuedTurn::new(turn)?);
202 }
203 Ok(Self::Built(turns))
204 }
205
206 fn next(&mut self, tokenizer: &mut impl TokenizerWorker) -> Result<Option<QueuedTurn>> {
207 match self {
208 Self::Built(turns) => Ok(turns.pop_front()),
209 Self::Lazy(builder) => builder
210 .next_turn(tokenizer)?
211 .map(QueuedTurn::new)
212 .transpose(),
213 }
214 }
215}
216
217#[derive(Debug)]
218struct SessionState {
219 turns: SessionTurns,
220 head: Option<HeadTurn>,
221 overlap_base: Option<OverlapBase>,
222 replay_base: Option<ReplayBase>,
223 next_turn_key: u64,
224}
225
226#[derive(Debug)]
227struct TokenizeJob {
228 session_id: String,
229 turn_key: u64,
230 current_text: String,
231 overlap_start: Option<usize>,
232 previous_overlap_text: Option<String>,
233 previous_tokens: Option<Vec<u32>>,
234 overlap_words: usize,
235}
236
237#[derive(Debug)]
238struct TokenizeResponse {
239 session_id: String,
240 turn_key: u64,
241 outcome: Result<ReadyTurn, String>,
242}
243
244impl FidelityVerifier {
245 fn new(oracle: SourceFidelityOracle) -> Self {
246 Self {
247 oracle,
248 seen_requests: BTreeSet::new(),
249 seen_compactions: BTreeSet::new(),
250 tools_by_class: BTreeMap::new(),
251 tool_count: 0,
252 tool_errors: 0,
253 child_links: 0,
254 background_tools: 0,
255 background_agents: 0,
256 usage_requests: 0,
257 cache_prefix_blocks_verified: 0,
258 compaction_prefix_blocks_verified: 0,
259 post_compaction_prefix_blocks_verified: 0,
260 next_turn_by_session: FxHashMap::default(),
261 previous_hashes_by_session: FxHashMap::default(),
262 previous_input_length_by_session: FxHashMap::default(),
263 previous_was_compaction_by_session: FxHashMap::default(),
264 expected_next_cache_read_by_session: FxHashMap::default(),
265 export_sessions: BTreeSet::new(),
266 causal_references: Vec::new(),
267 child_session_references: Vec::new(),
268 }
269 }
270
271 fn observe(
272 &mut self,
273 turn: &TurnDraft,
274 replay_tokens: &[u32],
275 input_sequence_hashes: &[u64],
276 block_size: usize,
277 ) -> Result<()> {
278 let key = (turn.session_id.clone(), turn.source_request_id.clone());
279 if let Some(compaction) = &turn.compaction {
280 let expected = self.oracle.compactions.get(&key).ok_or_else(|| {
281 anyhow!(
282 "fidelity verification found unexpected compaction {} in session {}",
283 turn.source_request_id,
284 turn.session_id
285 )
286 })?;
287 if compaction != expected || !self.seen_compactions.insert(key) {
288 bail!(
289 "fidelity verification compaction mismatch for session {} sequence {}",
290 turn.session_id,
291 compaction.sequence
292 );
293 }
294 let expected_turn = self
295 .next_turn_by_session
296 .get(&turn.session_id)
297 .copied()
298 .unwrap_or_default();
299 let expected_start = compaction
300 .ended_at_ms
301 .saturating_sub(compaction.duration_ms);
302 if turn.turn_index != expected_turn
303 || turn.request_start_ms != expected_start
304 || turn.assistant_end_ms != compaction.ended_at_ms
305 || turn.observed_input_length != Some(compaction.pre_tokens)
306 || turn.cache_read_input_tokens.is_some()
307 || replay_tokens.len() != compaction.pre_tokens
308 {
309 bail!(
310 "fidelity verification compaction timing/cache mismatch for session {} sequence {}",
311 turn.session_id,
312 compaction.sequence
313 );
314 }
315 } else {
316 let expected = self.oracle.requests.get(&key).ok_or_else(|| {
317 anyhow!(
318 "fidelity verification found unexpected request {} in session {}",
319 turn.source_request_id,
320 turn.session_id
321 )
322 })?;
323 if !self.seen_requests.insert(key) {
324 bail!(
325 "fidelity verification found duplicate request {} in session {}",
326 turn.source_request_id,
327 turn.session_id
328 );
329 }
330 let expected_turn = self
331 .next_turn_by_session
332 .entry(turn.session_id.clone())
333 .or_default();
334 if turn.turn_index != *expected_turn {
335 bail!(
336 "fidelity verification expected turn {} for session {}, got {}",
337 *expected_turn,
338 turn.session_id,
339 turn.turn_index
340 );
341 }
342 *expected_turn += 1;
343 if turn.request_start_ms != expected.request_start_ms
344 || turn.assistant_end_ms != expected.assistant_end_ms
345 {
346 bail!(
347 "fidelity verification timing mismatch for session {} turn {}: expected {}..{}, got {}..{}",
348 turn.session_id,
349 turn.turn_index,
350 expected.request_start_ms,
351 expected.assistant_end_ms,
352 turn.request_start_ms,
353 turn.assistant_end_ms
354 );
355 }
356 if let Some(output_length) = expected.output_length
357 && turn.output_length != output_length
358 {
359 bail!(
360 "fidelity verification output mismatch for session {} turn {}: expected {}, got {}",
361 turn.session_id,
362 turn.turn_index,
363 output_length,
364 turn.output_length
365 );
366 }
367 if let Some(input_length) = expected.input_length {
368 self.usage_requests += 1;
369 if replay_tokens.len() != input_length
370 || turn.cache_read_input_tokens != expected.cache_read_input_tokens
371 || turn.cache_creation_input_tokens != expected.cache_creation_input_tokens
372 {
373 bail!(
374 "fidelity verification input/cache mismatch for session {} turn {}",
375 turn.session_id,
376 turn.turn_index
377 );
378 }
379 }
380 }
381 self.export_sessions.insert(turn.export_session_id.clone());
382 if turn.request_start_ms > turn.assistant_start_ms
383 || turn.assistant_start_ms > turn.assistant_end_ms
384 {
385 bail!(
386 "invalid request timing for session {} turn {}",
387 turn.session_id,
388 turn.turn_index
389 );
390 }
391 let expected_hashes = replay_tokens.len().div_ceil(block_size);
392 if input_sequence_hashes.len() != expected_hashes {
393 bail!(
394 "fidelity verification expected {} hashes for session {} turn {}, got {}",
395 expected_hashes,
396 turn.session_id,
397 turn.turn_index,
398 input_sequence_hashes.len()
399 );
400 }
401 let previous_was_compaction = self
402 .previous_was_compaction_by_session
403 .get(&turn.session_id)
404 .copied()
405 .unwrap_or(false);
406 let previous_input_length = self
407 .previous_input_length_by_session
408 .get(&turn.session_id)
409 .copied();
410 let previous_hashes = self.previous_hashes_by_session.get(&turn.session_id);
411 if turn.compaction.is_some() && previous_hashes.is_none() {
412 bail!(
413 "fidelity verification cannot recover compaction prefix for session {}",
414 turn.session_id
415 );
416 }
417 if let (Some(previous_hashes), Some(previous_input_length)) =
418 (previous_hashes, previous_input_length)
419 {
420 let verifiable_blocks = if let Some(compaction) = &turn.compaction {
421 previous_input_length.min(compaction.pre_tokens.saturating_sub(1)) / block_size
422 } else {
423 let cached_blocks = turn.cache_read_input_tokens.unwrap_or(0) / block_size;
424 cached_blocks
425 .min(previous_input_length / block_size)
426 .min(previous_hashes.len())
427 .min(input_sequence_hashes.len())
428 };
429 if previous_hashes[..verifiable_blocks] != input_sequence_hashes[..verifiable_blocks] {
430 bail!(
431 "fidelity verification cached prefix mismatch for session {} turn {}",
432 turn.session_id,
433 turn.turn_index
434 );
435 }
436 self.cache_prefix_blocks_verified += verifiable_blocks;
437 if turn.compaction.is_some() {
438 if verifiable_blocks == 0 {
439 bail!(
440 "fidelity verification found no recoverable compaction prefix blocks for session {}",
441 turn.session_id
442 );
443 }
444 self.compaction_prefix_blocks_verified += verifiable_blocks;
445 } else if previous_was_compaction {
446 let cached_tokens = turn.cache_read_input_tokens.unwrap_or(0);
447 let cached_blocks = cached_tokens / block_size;
448 let cache_creation_tokens = turn.cache_creation_input_tokens.unwrap_or(0);
449 if cached_blocks == 0
450 || cache_creation_tokens == 0
451 || cached_tokens > previous_input_length
452 || verifiable_blocks != cached_blocks
453 {
454 bail!(
455 "fidelity verification found post-compaction cache miss for session {}",
456 turn.session_id
457 );
458 }
459 self.post_compaction_prefix_blocks_verified += verifiable_blocks;
460 self.expected_next_cache_read_by_session.insert(
461 turn.session_id.clone(),
462 cached_tokens.saturating_add(cache_creation_tokens),
463 );
464 }
465 }
466 if turn.compaction.is_none()
467 && !previous_was_compaction
468 && let Some(expected_cache_read) = self
469 .expected_next_cache_read_by_session
470 .remove(&turn.session_id)
471 && turn.cache_read_input_tokens != Some(expected_cache_read)
472 {
473 bail!(
474 "fidelity verification expected {} post-compaction cache-read tokens for session {}, got {:?}",
475 expected_cache_read,
476 turn.session_id,
477 turn.cache_read_input_tokens
478 );
479 }
480 self.previous_hashes_by_session
481 .insert(turn.session_id.clone(), input_sequence_hashes.to_vec());
482 self.previous_input_length_by_session
483 .insert(turn.session_id.clone(), replay_tokens.len());
484 self.previous_was_compaction_by_session
485 .insert(turn.session_id.clone(), turn.compaction.is_some());
486
487 for tool in &turn.tools {
488 if tool.started_at_ms > tool.ended_at_ms {
489 bail!(
490 "invalid tool timing for {} in session {}",
491 tool.tool_call_id,
492 turn.session_id
493 );
494 }
495 self.tool_count += 1;
496 *self
497 .tools_by_class
498 .entry(tool.tool_class.clone())
499 .or_insert(0) += 1;
500 self.tool_errors += usize::from(tool.is_error);
501 self.child_links += usize::from(tool.child_session_id.is_some());
502 if let Some(child_session_id) = &tool.child_session_id {
503 self.child_session_references.push(child_session_id.clone());
504 }
505 self.background_tools += usize::from(tool.execution_mode == "background");
506 self.background_agents +=
507 usize::from(tool.execution_mode == "background" && tool.child_session_id.is_some());
508 if !matches!(tool.execution_mode.as_str(), "blocking" | "background") {
509 bail!(
510 "fidelity verification found invalid execution mode {} for {}",
511 tool.execution_mode,
512 tool.tool_call_id
513 );
514 }
515 if tool.child_session_id.as_deref() == Some(turn.export_session_id.as_str()) {
516 bail!(
517 "fidelity verification found self-referential child session for {}",
518 tool.tool_call_id
519 );
520 }
521 if let Some(consumer_turn_index) = tool.consumer_turn_index {
522 if consumer_turn_index <= turn.turn_index {
523 bail!(
524 "fidelity verification found non-forward consumer for {}",
525 tool.tool_call_id
526 );
527 }
528 self.causal_references.push((
529 turn.session_id.clone(),
530 consumer_turn_index,
531 tool.tool_call_id.clone(),
532 ));
533 }
534 }
535 Ok(())
536 }
537
538 fn finish(
539 self,
540 request_rows: usize,
541 tool_rows: usize,
542 sidecar_rows: usize,
543 ) -> Result<FidelityReport> {
544 for (session_id, consumer_turn_index, tool_call_id) in &self.causal_references {
545 let turn_count = self
546 .next_turn_by_session
547 .get(session_id)
548 .copied()
549 .unwrap_or(0);
550 if *consumer_turn_index >= turn_count {
551 bail!(
552 "fidelity verification found missing consumer turn {} for {}",
553 consumer_turn_index,
554 tool_call_id
555 );
556 }
557 }
558 let unresolved_child_sessions = self
559 .child_session_references
560 .iter()
561 .filter(|session_id| !self.export_sessions.contains(*session_id))
562 .count();
563 let source_request_rows = self.oracle.requests.len() + self.oracle.compactions.len();
564 let seen_request_rows = self.seen_requests.len() + self.seen_compactions.len();
565 if request_rows != source_request_rows
566 || request_rows != seen_request_rows
567 || self.seen_compactions.len() != self.oracle.compactions.len()
568 || sidecar_rows != request_rows
569 {
570 bail!(
571 "fidelity verification request mismatch: source={} ({} compactions), emitted={}, sidecar={}",
572 source_request_rows,
573 self.oracle.compactions.len(),
574 request_rows,
575 sidecar_rows
576 );
577 }
578 if tool_rows != self.oracle.paired_tools
579 || self.tool_count != self.oracle.paired_tools
580 || self.tool_errors != self.oracle.tool_errors
581 || self.tools_by_class != self.oracle.tools_by_class
582 {
583 bail!(
584 "fidelity verification tool mismatch: count={}/{}, errors={}/{}, classes_equal={}",
585 self.oracle.paired_tools,
586 tool_rows,
587 self.oracle.tool_errors,
588 self.tool_errors,
589 self.tools_by_class == self.oracle.tools_by_class
590 );
591 }
592 if self.child_links != self.oracle.child_links
593 || self.background_tools != self.oracle.background_tools
594 || self.background_agents != self.oracle.background_agents
595 {
596 bail!(
597 "fidelity verification agent mismatch: child_links={}/{}, background_tools={}/{}, background_agents={}/{}",
598 self.oracle.child_links,
599 self.child_links,
600 self.oracle.background_tools,
601 self.background_tools,
602 self.oracle.background_agents,
603 self.background_agents
604 );
605 }
606 Ok(FidelityReport {
607 requests_verified: request_rows,
608 compactions_verified: self.seen_compactions.len(),
609 usage_requests_verified: self.usage_requests,
610 tools_verified: tool_rows,
611 child_links_verified: self.child_links,
612 background_tools: self.background_tools,
613 background_agents: self.background_agents,
614 background_completions_missing: self.oracle.background_completions_missing,
615 background_titles_unreplayable: self.oracle.background_titles.len(),
616 cache_prefix_blocks_verified: self.cache_prefix_blocks_verified,
617 compaction_prefix_blocks_verified: self.compaction_prefix_blocks_verified,
618 post_compaction_prefix_blocks_verified: self.post_compaction_prefix_blocks_verified,
619 pooled_prefix_blocks: 0,
620 unmatched_tool_calls: self.oracle.unmatched_tool_calls,
621 unmatched_tool_results: self.oracle.unmatched_tool_results,
622 unresolved_child_sessions,
623 })
624 }
625}
626
627pub trait SessionSource {
629 fn session_bounds(&self) -> Vec<(String, Option<i64>)>;
631
632 fn take_session(&mut self, trace_id: &str) -> Result<Vec<TraceRecord>>;
634}
635
636impl SessionSource for TraceIndex {
637 fn session_bounds(&self) -> Vec<(String, Option<i64>)> {
638 self.sessions()
639 .map(|(trace_id, bound)| (trace_id.to_string(), bound))
640 .collect()
641 }
642
643 fn take_session(&mut self, trace_id: &str) -> Result<Vec<TraceRecord>> {
644 self.load_session(trace_id)
645 }
646}
647
648impl SessionSource for FxHashMap<String, Vec<TraceRecord>> {
649 fn session_bounds(&self) -> Vec<(String, Option<i64>)> {
650 self.iter()
651 .map(|(trace_id, records)| {
652 let bound = records.iter().filter_map(request_start_bound_ms).min();
653 (trace_id.clone(), bound)
654 })
655 .collect()
656 }
657
658 fn take_session(&mut self, trace_id: &str) -> Result<Vec<TraceRecord>> {
659 self.remove(trace_id)
660 .ok_or_else(|| anyhow!("unknown Claude session {trace_id}"))
661 }
662}
663
664pub fn write_streamed_request_trace_rows<F, S>(
669 output_path: &Path,
670 sidecar_path: &Path,
671 mut sessions: S,
672 preserve_session_ids: bool,
673 tokenizer_factory: F,
674 config: ExportConfig,
675) -> Result<ExportStats>
676where
677 F: TokenizerFactory,
678 S: SessionSource,
679{
680 if config.block_size == 0 {
681 bail!("block_size must be greater than 0");
682 }
683 if config.tokenizer_workers == 0 {
684 bail!("tokenizer_workers must be greater than 0");
685 }
686
687 let mut verifier = FidelityVerifier::new(SourceFidelityOracle::default());
688 let mut parser_tokenizer = LazyTokenizer::new(tokenizer_factory.clone());
689 let mut states = FxHashMap::default();
690 let mut heap = BinaryHeap::new();
691 let mut unscheduled_sessions = VecDeque::new();
692 let mut stats = ExportStats::default();
693 let mut unopened = sessions.session_bounds();
694 unopened.sort_by(|left, right| {
695 (left.1.unwrap_or(i64::MIN), &left.0).cmp(&(right.1.unwrap_or(i64::MIN), &right.0))
696 });
697 let mut unopened = unopened.into_iter().peekable();
698
699 let mut output = create_writer(output_path)?;
700 let mut sidecar = create_writer(sidecar_path)?;
701
702 let (job_tx, job_rx) = bounded::<TokenizeJob>(config.tokenizer_workers);
703 let (result_tx, result_rx) = unbounded::<TokenizeResponse>();
704 let workers = spawn_tokenizer_workers(
705 tokenizer_factory,
706 config.tokenizer_workers,
707 job_rx,
708 result_tx,
709 );
710
711 let mut trace_start_ms = None;
712 let mut prefix_pool = PrefixPool::default();
713 let mut pooled_prefix_blocks = 0_usize;
714 let mut inflight_jobs = 0_usize;
715 loop {
716 while let Some((_, bound)) = unopened.peek() {
719 let earliest_head_ms = heap
720 .peek()
721 .map(|Reverse(entry): &Reverse<HeapEntry>| entry.request_start_ms);
722 if let (Some(bound), Some(earliest_head_ms)) = (bound, earliest_head_ms)
723 && *bound > earliest_head_ms
724 {
725 break;
726 }
727 let (session_id, _) = unopened.next().expect("peeked session");
728 let records = sessions.take_session(&session_id)?;
729 verifier.oracle.add_session(&session_id, &records)?;
730 let builder =
731 SessionTurnBuilder::new(session_id.clone(), records, preserve_session_ids);
732 let mut turns = SessionTurns::open(builder, &mut parser_tokenizer)?;
733 let Some(first_turn) = turns.next(&mut parser_tokenizer)? else {
734 continue;
735 };
736 let state = SessionState {
737 turns,
738 head: Some(head_turn(first_turn, 0)),
739 overlap_base: None,
740 replay_base: None,
741 next_turn_key: 1,
742 };
743 push_heap_entry(&mut heap, &session_id, &state);
744 states.insert(session_id.clone(), state);
745 unscheduled_sessions.push_back(session_id);
746 stats.max_heap_len = stats.max_heap_len.max(heap.len());
747 }
748 if heap.is_empty() {
749 break;
750 }
751 schedule_pending_jobs(
752 &mut states,
753 &mut unscheduled_sessions,
754 &job_tx,
755 &mut inflight_jobs,
756 config.delta_overlap_words,
757 config.tokenizer_workers,
758 )?;
759
760 let Some(Reverse(entry)) = heap.peek() else {
761 break;
762 };
763 let head_ready = states
764 .get(&entry.session_id)
765 .and_then(|state| state.head.as_ref())
766 .and_then(|head| head.ready.as_ref())
767 .is_some();
768 if !head_ready {
769 let response = result_rx
770 .recv()
771 .map_err(|_| anyhow!("tokenizer worker channel closed unexpectedly"))?;
772 inflight_jobs = inflight_jobs.saturating_sub(1);
773 apply_tokenize_response(&mut states, response)?;
774 continue;
775 }
776
777 let Reverse(entry) = heap.pop().unwrap();
778 let session_id = entry.session_id.clone();
779 let (turn, sidecar_line, ready_turn) = {
780 let state = states
781 .get_mut(&session_id)
782 .ok_or_else(|| anyhow!("missing session state for {}", session_id))?;
783 let mut head = state
784 .head
785 .take()
786 .ok_or_else(|| anyhow!("missing head for session {}", session_id))?;
787 let ready_turn = head
788 .ready
789 .take()
790 .ok_or_else(|| anyhow!("missing tokenized result for session {}", session_id))?;
791 (head.turn, head.sidecar_line, ready_turn)
792 };
793
794 let trace_start_ms = *trace_start_ms.get_or_insert(turn.request_start_ms);
795 let next_turn = {
796 let state = states
797 .get_mut(&session_id)
798 .ok_or_else(|| anyhow!("missing session state for {}", session_id))?;
799 state.turns.next(&mut parser_tokenizer)?
800 };
801 let replay = {
802 let state = states
803 .get(&session_id)
804 .ok_or_else(|| anyhow!("missing session state for {}", session_id))?;
805 let cached_tokens = turn.cache_read_input_tokens.unwrap_or(0);
806 let source = match state.replay_base.as_ref() {
807 Some(base) => Some(base),
808 None if turn.compaction.is_none()
809 && turn.observed_input_length.is_some()
810 && cached_tokens > 0 =>
811 {
812 Some(prefix_pool.prefix(
813 &turn.prefix_pool_key,
814 cached_tokens,
815 config.block_size,
816 )?)
817 }
818 None => None,
819 };
820 let (tokens, shared_tokens) = materialize_replay_tokens(
821 &turn,
822 &ready_turn.tokens,
823 source.map(|base| base.tokens.as_slice()),
824 );
825 if state.replay_base.is_none() {
826 pooled_prefix_blocks += shared_tokens / config.block_size;
827 }
828 ReplayBase::derive(source, shared_tokens, tokens, config.block_size)?
829 };
830 verifier.observe(&turn, &replay.tokens, &replay.hashes, config.block_size)?;
831 let request_id = turn.compaction.as_ref().map_or_else(
832 || canonical_request_id(&turn.export_session_id, turn.turn_index),
833 |compaction| {
834 canonical_compaction_request_id(&turn.export_session_id, compaction.sequence)
835 },
836 );
837 let agent_context = AgentContext {
838 session_id: &turn.export_session_id,
839 parent_session_id: turn.export_parent_session_id.as_deref(),
840 };
841 let claude = turn.compaction.as_ref().map(|compaction| {
842 json!({
843 "compaction": {
844 "trigger": compaction.trigger,
845 "pre_tokens": compaction.pre_tokens,
846 "post_tokens": compaction.post_tokens,
847 "duration_ms": compaction.duration_ms,
848 "cache_fidelity": "recoverable_cache_safe_prefix",
849 "output_fidelity": "tokenized_compact_summary",
850 }
851 })
852 });
853 let row = TraceLine {
854 timestamp: nonnegative_ms(turn.assistant_end_ms - trace_start_ms),
855 event: RequestEndEvent {
856 schema: REQUEST_TRACE_SCHEMA,
857 event_type: "request_end",
858 event_time_unix_ms: nonnegative_ms(turn.assistant_end_ms),
859 event_source: HARNESS_EVENT_SOURCE,
860 agent_context: &agent_context,
861 request: RequestFields {
862 request_id: &request_id,
863 model: &turn.model,
864 input_tokens: replay.tokens.len(),
865 output_tokens: turn.output_length,
866 request_received_ms: nonnegative_ms(turn.request_start_ms),
867 total_time_ms: (turn.assistant_end_ms - turn.request_start_ms).max(0) as f64,
868 replay: ReplayFields {
869 trace_block_size: config.block_size,
870 input_length: replay.tokens.len(),
871 input_sequence_hashes: &replay.hashes,
872 dependencies: &[],
873 },
874 cached_tokens: turn.cache_read_input_tokens,
875 claude,
876 },
877 },
878 };
879 write_json_line(&mut output, &row)?;
880 for tool in &turn.tools {
881 let claude = ClaudeToolReplayMetadata {
882 source_request_id: request_id.clone(),
883 consumer_request_id: tool
884 .consumer_turn_index
885 .map(|turn_index| canonical_request_id(&turn.export_session_id, turn_index)),
886 child_session_id: tool.child_session_id.clone(),
887 execution_mode: tool.execution_mode.clone(),
888 };
889 let tool_row = TraceLine {
890 timestamp: nonnegative_ms(tool.ended_at_ms - trace_start_ms),
891 event: ToolEvent {
892 schema: REQUEST_TRACE_SCHEMA,
893 event_type: if tool.is_error {
894 "tool_error"
895 } else {
896 "tool_end"
897 },
898 event_time_unix_ms: nonnegative_ms(tool.ended_at_ms),
899 event_source: HARNESS_EVENT_SOURCE,
900 agent_context: &agent_context,
901 tool: ToolFields {
902 tool_call_id: &tool.tool_call_id,
903 tool_class: &tool.tool_class,
904 claude: Some(claude),
905 started_at_unix_ms: nonnegative_ms(tool.started_at_ms),
906 ended_at_unix_ms: nonnegative_ms(tool.ended_at_ms),
907 duration_ms: (tool.ended_at_ms - tool.started_at_ms).max(0) as f64,
908 status: if tool.is_error { "error" } else { "succeeded" },
909 output_bytes: Some(tool.output_bytes),
910 error_type: tool.is_error.then_some("claude_tool_error"),
911 },
912 },
913 };
914 write_json_line(&mut output, &tool_row)?;
915 stats.tool_row_count += 1;
916 }
917 sidecar.write_all(sidecar_line.as_bytes())?;
918 sidecar.write_all(b"\n")?;
919 stats.row_count += 1;
920 stats.sidecar_count += 1;
921
922 let state = states
923 .get_mut(&session_id)
924 .ok_or_else(|| anyhow!("missing session state for {}", session_id))?;
925 state.overlap_base = turn.observed_input_length.is_none().then_some(OverlapBase {
926 previous_text: ready_turn.current_text,
927 previous_tokens: ready_turn.tokens,
928 });
929 state.replay_base = Some(replay);
930
931 if let Some(next_turn) = next_turn {
932 let turn_key = state.next_turn_key;
933 state.next_turn_key += 1;
934 state.head = Some(head_turn(next_turn, turn_key));
935 push_heap_entry(&mut heap, &session_id, state);
936 unscheduled_sessions.push_back(session_id);
937 stats.max_heap_len = stats.max_heap_len.max(heap.len());
938 continue;
939 }
940
941 states.remove(&session_id);
942 }
943
944 drop(job_tx);
945 for worker in workers {
946 worker
947 .join()
948 .map_err(|_| anyhow!("tokenizer worker panicked"))?;
949 }
950 stats.fidelity = verifier.finish(stats.row_count, stats.tool_row_count, stats.sidecar_count)?;
951 stats.fidelity.pooled_prefix_blocks = pooled_prefix_blocks;
952 output.flush()?;
953 sidecar.flush()?;
954 Ok(stats)
955}
956
957fn create_writer(path: &Path) -> Result<BufWriter<File>> {
958 if let Some(parent) = path.parent() {
959 std::fs::create_dir_all(parent)?;
960 }
961 Ok(BufWriter::new(File::create(path)?))
962}
963
964fn write_json_line(writer: &mut impl Write, value: &impl Serialize) -> Result<()> {
965 serde_json::to_writer(&mut *writer, value)?;
966 writer.write_all(b"\n")?;
967 Ok(())
968}
969
970fn nonnegative_ms(value: i64) -> u64 {
971 value.max(0) as u64
972}
973
974fn canonical_request_id(session_id: &str, turn_index: usize) -> String {
975 format!("claude:{session_id}:{turn_index}")
976}
977
978fn canonical_compaction_request_id(session_id: &str, sequence: usize) -> String {
979 format!("claude:{session_id}:compact:{sequence}")
980}
981
982fn materialize_replay_tokens(
987 turn: &TurnDraft,
988 rendered_tokens: &[u32],
989 prefix_source: Option<&[u32]>,
990) -> (Vec<u32>, usize) {
991 let Some(input_length) = turn.observed_input_length else {
992 return (rendered_tokens.to_vec(), 0);
993 };
994 let seed = synthetic_stream_seed(&turn.export_session_id);
995
996 if turn.compaction.is_some() {
997 let shared_length = prefix_source
998 .map(<[u32]>::len)
999 .unwrap_or_default()
1000 .min(input_length.saturating_sub(1));
1001 let mut tokens = Vec::with_capacity(input_length);
1002 if let Some(prefix_source) = prefix_source {
1003 tokens.extend_from_slice(&prefix_source[..shared_length]);
1004 }
1005 let start = tokens.len();
1006 tokens.extend(
1007 (start..input_length).map(|position| synthetic_token(seed, turn.turn_index, position)),
1008 );
1009 return (tokens, shared_length);
1010 }
1011
1012 usage_shaped_tokens(
1013 seed,
1014 turn.turn_index,
1015 input_length,
1016 turn.cache_read_input_tokens.unwrap_or(0),
1017 prefix_source,
1018 )
1019}
1020
1021fn head_turn(queued: QueuedTurn, turn_key: u64) -> HeadTurn {
1023 let ready = queued
1024 .turn
1025 .observed_input_length
1026 .is_some()
1027 .then(|| ReadyTurn {
1028 current_text: String::new(),
1029 tokens: Vec::new(),
1030 });
1031 HeadTurn {
1032 turn: queued.turn,
1033 sidecar_line: queued.sidecar_line,
1034 turn_key,
1035 scheduled: false,
1036 ready,
1037 }
1038}
1039
1040fn push_heap_entry(
1041 heap: &mut BinaryHeap<Reverse<HeapEntry>>,
1042 session_id: &str,
1043 state: &SessionState,
1044) {
1045 if let Some(head) = state.head.as_ref() {
1046 heap.push(Reverse(HeapEntry {
1047 request_start_ms: head.turn.request_start_ms,
1048 turn_index: head.turn.turn_index,
1049 export_session_id: head.turn.export_session_id.clone(),
1050 session_id: session_id.to_string(),
1051 }));
1052 }
1053}
1054
1055fn schedule_pending_jobs(
1056 states: &mut FxHashMap<String, SessionState>,
1057 unscheduled_sessions: &mut VecDeque<String>,
1058 job_tx: &Sender<TokenizeJob>,
1059 inflight_jobs: &mut usize,
1060 overlap_words: usize,
1061 worker_limit: usize,
1062) -> Result<()> {
1063 while *inflight_jobs < worker_limit {
1064 let Some(session_id) = unscheduled_sessions.pop_front() else {
1065 return Ok(());
1066 };
1067 let Some(state) = states.get_mut(&session_id) else {
1068 continue;
1069 };
1070 let Some(head) = state.head.as_mut() else {
1071 continue;
1072 };
1073 if head.scheduled || head.ready.is_some() {
1074 continue;
1075 }
1076
1077 let overlap_base = state.overlap_base.take();
1078 let current_text = std::mem::take(&mut head.turn.input_text);
1079 let (overlap_start, previous_overlap_text, previous_tokens) =
1080 prepare_overlap_inputs(overlap_base, ¤t_text, overlap_words);
1081 let job = TokenizeJob {
1082 session_id: session_id.clone(),
1083 turn_key: head.turn_key,
1084 current_text,
1085 overlap_start,
1086 previous_overlap_text,
1087 previous_tokens,
1088 overlap_words,
1089 };
1090 job_tx
1091 .send(job)
1092 .map_err(|_| anyhow!("failed to schedule tokenization job"))?;
1093 head.scheduled = true;
1094 *inflight_jobs += 1;
1095 }
1096 Ok(())
1097}
1098
1099fn apply_tokenize_response(
1100 states: &mut FxHashMap<String, SessionState>,
1101 response: TokenizeResponse,
1102) -> Result<()> {
1103 let Some(state) = states.get_mut(&response.session_id) else {
1104 return Ok(());
1105 };
1106 let Some(head) = state.head.as_mut() else {
1107 return Ok(());
1108 };
1109 if head.turn_key != response.turn_key {
1110 return Ok(());
1111 }
1112 head.scheduled = false;
1113 match response.outcome {
1114 Ok(ready) => {
1115 head.ready = Some(ready);
1116 Ok(())
1117 }
1118 Err(message) => bail!("{message}"),
1119 }
1120}
1121
1122fn prepare_overlap_inputs(
1123 overlap_base: Option<OverlapBase>,
1124 current_text: &str,
1125 overlap_words: usize,
1126) -> (Option<usize>, Option<String>, Option<Vec<u32>>) {
1127 if overlap_words == 0 {
1128 return (None, None, None);
1129 }
1130 let Some(overlap_base) = overlap_base else {
1131 return (None, None, None);
1132 };
1133 if !current_text.starts_with(&overlap_base.previous_text) {
1134 return (None, None, None);
1135 }
1136
1137 let overlap_start = last_word_overlap_start(&overlap_base.previous_text, overlap_words);
1138 (
1139 Some(overlap_start),
1140 Some(overlap_base.previous_text[overlap_start..].to_string()),
1141 Some(overlap_base.previous_tokens),
1142 )
1143}
1144
1145fn spawn_tokenizer_workers<F>(
1146 factory: F,
1147 worker_count: usize,
1148 job_rx: Receiver<TokenizeJob>,
1149 result_tx: Sender<TokenizeResponse>,
1150) -> Vec<JoinHandle<()>>
1151where
1152 F: TokenizerFactory,
1153{
1154 (0..worker_count)
1155 .map(|_| {
1156 let job_rx = job_rx.clone();
1157 let result_tx = result_tx.clone();
1158 let factory = factory.clone();
1159 thread::spawn(move || {
1160 let mut tokenizer = LazyTokenizer::new(factory);
1161 while let Ok(job) = job_rx.recv() {
1162 let outcome = tokenize_job(&mut tokenizer, &job)
1163 .map(|tokens| ReadyTurn {
1164 current_text: job.current_text,
1165 tokens,
1166 })
1167 .map_err(|error| {
1168 format!("failed to tokenize session {}: {error:#}", job.session_id)
1169 });
1170 let _ = result_tx.send(TokenizeResponse {
1171 session_id: job.session_id,
1172 turn_key: job.turn_key,
1173 outcome,
1174 });
1175 }
1176 })
1177 })
1178 .collect()
1179}
1180
1181fn tokenize_job(tokenizer: &mut impl TokenizerWorker, job: &TokenizeJob) -> Result<Vec<u32>> {
1182 let Some(overlap_start) = job.overlap_start else {
1183 return tokenizer.encode(&job.current_text);
1184 };
1185 let Some(previous_overlap_text) = job.previous_overlap_text.as_deref() else {
1186 return tokenizer.encode(&job.current_text);
1187 };
1188 let Some(previous_tokens) = job.previous_tokens.as_deref() else {
1189 return tokenizer.encode(&job.current_text);
1190 };
1191 if job.overlap_words == 0 || !job.current_text.is_char_boundary(overlap_start) {
1192 return tokenizer.encode(&job.current_text);
1193 }
1194
1195 let previous_overlap_tokens = tokenizer.encode(previous_overlap_text)?;
1196 let prefix_token_count = previous_tokens
1197 .len()
1198 .saturating_sub(previous_overlap_tokens.len());
1199 let suffix_tokens = tokenizer.encode(&job.current_text[overlap_start..])?;
1200 let mut merged = Vec::with_capacity(prefix_token_count + suffix_tokens.len());
1201 merged.extend_from_slice(&previous_tokens[..prefix_token_count]);
1202 merged.extend(suffix_tokens);
1203 Ok(merged)
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::{
1209 ExportConfig, HeadTurn, ReadyTurn, SessionState, SessionTurns, TurnDraft,
1210 apply_tokenize_response, write_streamed_request_trace_rows,
1211 };
1212 use crate::coding::claude::parser::TraceRecord;
1213 use crate::coding::tokenizer::{TokenizerFactory, TokenizerWorker};
1214 use anyhow::Result;
1215 use rustc_hash::FxHashMap;
1216 use serde_json::{Value, json};
1217 use std::sync::{Arc, Mutex};
1218 use std::thread;
1219 use std::time::Duration;
1220 use tempfile::TempDir;
1221
1222 #[derive(Clone, Default)]
1223 struct StubFactory {
1224 calls: Arc<Mutex<Vec<String>>>,
1225 }
1226
1227 struct StubWorker {
1228 calls: Arc<Mutex<Vec<String>>>,
1229 }
1230
1231 impl TokenizerFactory for StubFactory {
1232 type Worker = StubWorker;
1233
1234 fn create_worker(&self) -> Result<Self::Worker> {
1235 Ok(StubWorker {
1236 calls: self.calls.clone(),
1237 })
1238 }
1239 }
1240
1241 impl TokenizerWorker for StubWorker {
1242 fn encode(&mut self, text: &str) -> Result<Vec<u32>> {
1243 if text.contains("slow") {
1244 thread::sleep(Duration::from_millis(20));
1245 }
1246 self.calls.lock().unwrap().push(text.to_string());
1247 Ok(text
1248 .split_whitespace()
1249 .map(|word| word.len() as u32)
1250 .collect())
1251 }
1252 }
1253
1254 fn make_record(
1255 session_id: &str,
1256 row_type: &str,
1257 timestamp_ms: i64,
1258 source_order: u64,
1259 raw: Value,
1260 ) -> TraceRecord {
1261 TraceRecord {
1262 session_id: session_id.to_string(),
1263 parent_session_id: None,
1264 row_type: row_type.to_string(),
1265 timestamp_ms,
1266 source_order,
1267 raw,
1268 }
1269 }
1270
1271 #[test]
1272 fn stale_result_is_dropped_by_turn_key() {
1273 let mut states = FxHashMap::default();
1274 states.insert(
1275 "session-a".to_string(),
1276 SessionState {
1277 turns: SessionTurns::Built(std::collections::VecDeque::new()),
1278 head: Some(HeadTurn {
1279 sidecar_line: String::new(),
1280 turn: TurnDraft {
1281 session_id: "session-a".to_string(),
1282 source_request_id: "req-1".to_string(),
1283 export_session_id: "session-a".to_string(),
1284 export_parent_session_id: None,
1285 turn_index: 1,
1286 model: "test-model".to_string(),
1287 input_text: String::new(),
1288 prefix_pool_key: String::new(),
1289 output_length: 1,
1290 observed_input_length: None,
1291 cache_read_input_tokens: None,
1292 cache_creation_input_tokens: None,
1293 request_start_ms: 1,
1294 assistant_start_ms: 1,
1295 assistant_end_ms: 2,
1296 delay_ms: None,
1297 tools: Vec::new(),
1298 sidecar: json!({}),
1299 compaction: None,
1300 },
1301 turn_key: 9,
1302 scheduled: true,
1303 ready: None,
1304 }),
1305 overlap_base: None,
1306 replay_base: None,
1307 next_turn_key: 10,
1308 },
1309 );
1310
1311 apply_tokenize_response(
1312 &mut states,
1313 super::TokenizeResponse {
1314 session_id: "session-a".to_string(),
1315 turn_key: 7,
1316 outcome: Ok(ReadyTurn {
1317 current_text: "stale".to_string(),
1318 tokens: vec![1],
1319 }),
1320 },
1321 )
1322 .unwrap();
1323
1324 assert!(
1325 states
1326 .get("session-a")
1327 .unwrap()
1328 .head
1329 .as_ref()
1330 .unwrap()
1331 .ready
1332 .is_none()
1333 );
1334 }
1335
1336 #[test]
1337 fn streamed_writer_preserves_global_order_with_parallel_tokenization() {
1338 let temp = TempDir::new().unwrap();
1339 let output_path = temp.path().join("trace.jsonl");
1340 let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1341 let mut sessions = FxHashMap::default();
1342 sessions.insert(
1343 "session-a".to_string(),
1344 vec![
1345 make_record(
1346 "session-a",
1347 "user",
1348 1_000,
1349 0,
1350 json!({"type":"user","message":{"role":"user","content":"slow first a"}}),
1351 ),
1352 make_record(
1353 "session-a",
1354 "assistant",
1355 2_000,
1356 1,
1357 json!({"type":"assistant","message":{"id":"a-1","content":[{"type":"text","text":"done a"}],"usage":{"input_tokens":4,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":3}}}),
1358 ),
1359 make_record(
1360 "session-a",
1361 "user",
1362 2_100,
1363 2,
1364 json!({"type":"user","message":{"role":"user","content":"follow a"}}),
1365 ),
1366 make_record(
1367 "session-a",
1368 "assistant",
1369 2_200,
1370 3,
1371 json!({"type":"assistant","message":{"id":"a-2","content":[{"type":"text","text":"done a 2"}],"usage":{"input_tokens":2,"cache_read_input_tokens":4,"cache_creation_input_tokens":0,"output_tokens":4}}}),
1372 ),
1373 ],
1374 );
1375 sessions.insert(
1376 "session-b".to_string(),
1377 vec![
1378 make_record(
1379 "session-b",
1380 "user",
1381 900,
1382 4,
1383 json!({"type":"user","message":{"role":"user","content":"first b"}}),
1384 ),
1385 make_record(
1386 "session-b",
1387 "assistant",
1388 1_100,
1389 5,
1390 json!({"type":"assistant","message":{"id":"b-1","content":[{"type":"text","text":"done b"}],"usage":{"output_tokens":2}}}),
1391 ),
1392 ],
1393 );
1394
1395 let stats = write_streamed_request_trace_rows(
1396 &output_path,
1397 &sidecar_path,
1398 sessions,
1399 true,
1400 StubFactory::default(),
1401 ExportConfig {
1402 block_size: 2,
1403 delta_overlap_words: 50,
1404 tokenizer_workers: 2,
1405 },
1406 )
1407 .unwrap();
1408
1409 let rows = std::fs::read_to_string(&output_path)
1410 .unwrap()
1411 .lines()
1412 .map(|line| serde_json::from_str::<Value>(line).unwrap())
1413 .collect::<Vec<_>>();
1414 let sidecar_rows = std::fs::read_to_string(&sidecar_path)
1415 .unwrap()
1416 .lines()
1417 .map(|line| serde_json::from_str::<Value>(line).unwrap())
1418 .collect::<Vec<_>>();
1419
1420 assert_eq!(stats.row_count, 3);
1421 assert_eq!(stats.sidecar_count, 3);
1422 assert!(stats.max_heap_len <= 2);
1423 assert_eq!(rows.len(), 3);
1424 assert_eq!(sidecar_rows.len(), 3);
1425 assert_eq!(rows[0]["event"]["agent_context"]["session_id"], "session-b");
1426 assert_eq!(rows[1]["event"]["agent_context"]["session_id"], "session-a");
1427 assert!(
1428 rows[1]["event"]["agent_context"]
1429 .get("session_final")
1430 .is_none()
1431 );
1432 assert_eq!(rows[2]["event"]["request"]["request_received_ms"], 2_100);
1433 assert_eq!(rows[1]["event"]["request"]["replay"]["input_length"], 4);
1434 assert_eq!(rows[2]["event"]["request"]["replay"]["input_length"], 6);
1435 let first_hashes = rows[1]["event"]["request"]["replay"]["input_sequence_hashes"]
1436 .as_array()
1437 .unwrap();
1438 let second_hashes = rows[2]["event"]["request"]["replay"]["input_sequence_hashes"]
1439 .as_array()
1440 .unwrap();
1441 assert_eq!(first_hashes.as_slice(), &second_hashes[..2]);
1442 }
1443
1444 #[test]
1445 fn transcript_shaped_sessions_tokenize_during_the_merge() {
1446 let temp = TempDir::new().unwrap();
1447 let output_path = temp.path().join("trace.jsonl");
1448 let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1449 let mut sessions = FxHashMap::default();
1450 sessions.insert(
1451 "session-u".to_string(),
1452 vec![
1453 make_record(
1454 "session-u",
1455 "user",
1456 1_000,
1457 0,
1458 json!({"type":"user","message":{"role":"user","content":"usage prompt"}}),
1459 ),
1460 make_record(
1461 "session-u",
1462 "assistant",
1463 1_100,
1464 1,
1465 json!({"type":"assistant","message":{"id":"u-1","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":4,"output_tokens":1}}}),
1466 ),
1467 ],
1468 );
1469 sessions.insert(
1470 "session-t".to_string(),
1471 vec![
1472 make_record(
1473 "session-t",
1474 "user",
1475 1_050,
1476 2,
1477 json!({"type":"user","message":{"role":"user","content":"first t prompt"}}),
1478 ),
1479 make_record(
1480 "session-t",
1481 "assistant",
1482 1_200,
1483 3,
1484 json!({"type":"assistant","message":{"id":"t-1","content":[{"type":"text","text":"answer"}]}}),
1485 ),
1486 make_record(
1487 "session-t",
1488 "user",
1489 1_300,
1490 4,
1491 json!({"type":"user","message":{"role":"user","content":"second t"}}),
1492 ),
1493 make_record(
1494 "session-t",
1495 "assistant",
1496 1_400,
1497 5,
1498 json!({"type":"assistant","message":{"id":"t-2","content":[{"type":"text","text":"again"}]}}),
1499 ),
1500 ],
1501 );
1502 let factory = StubFactory::default();
1503 let calls = factory.calls.clone();
1504
1505 let stats = write_streamed_request_trace_rows(
1506 &output_path,
1507 &sidecar_path,
1508 sessions,
1509 true,
1510 factory,
1511 ExportConfig {
1512 block_size: 2,
1513 delta_overlap_words: 50,
1514 tokenizer_workers: 2,
1515 },
1516 )
1517 .unwrap();
1518
1519 let rows = std::fs::read_to_string(&output_path)
1520 .unwrap()
1521 .lines()
1522 .map(|line| serde_json::from_str::<Value>(line).unwrap())
1523 .collect::<Vec<_>>();
1524 assert_eq!(stats.row_count, 3);
1525 let order = rows
1526 .iter()
1527 .map(|row| row["event"]["request"]["request_id"].as_str().unwrap())
1528 .collect::<Vec<_>>();
1529 assert_eq!(
1530 order,
1531 [
1532 "claude:session-u:0",
1533 "claude:session-t:0",
1534 "claude:session-t:1"
1535 ]
1536 );
1537 assert_eq!(rows[1]["event"]["request"]["replay"]["input_length"], 4);
1539 assert!(
1540 rows[2]["event"]["request"]["replay"]["input_length"]
1541 .as_u64()
1542 .unwrap()
1543 > 4
1544 );
1545 assert!(
1547 calls
1548 .lock()
1549 .unwrap()
1550 .iter()
1551 .all(|text| !text.contains("usage prompt"))
1552 );
1553 }
1554
1555 #[test]
1556 fn streamed_writer_replays_cache_safe_compaction() {
1557 use dynamo_data_gen::request_trace::{
1558 agentic::lower_agentic_mooncake_rows, load::load_request_trace_records,
1559 };
1560
1561 let temp = TempDir::new().unwrap();
1562 let output_path = temp.path().join("trace.jsonl");
1563 let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1564 let mut sessions = FxHashMap::default();
1565 sessions.insert(
1566 "session-a".to_string(),
1567 vec![
1568 make_record(
1569 "session-a",
1570 "user",
1571 1_000,
1572 0,
1573 json!({"type":"user","message":{"role":"user","content":"first prompt"}}),
1574 ),
1575 make_record(
1576 "session-a",
1577 "assistant",
1578 1_100,
1579 1,
1580 json!({"type":"assistant","requestId":"req-0","message":{"id":"a-0","model":"test-model","content":[{"type":"text","text":"first answer"}],"usage":{"input_tokens":8,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":2}}}),
1581 ),
1582 make_record(
1583 "session-a",
1584 "system",
1585 2_000,
1586 2,
1587 json!({"type":"system","subtype":"compact_boundary","compactMetadata":{"trigger":"manual","preTokens":10,"postTokens":3,"durationMs":500}}),
1588 ),
1589 make_record(
1590 "session-a",
1591 "user",
1592 2_000,
1593 3,
1594 json!({"type":"user","isCompactSummary":true,"message":{"role":"user","content":"compact summary"}}),
1595 ),
1596 make_record(
1597 "session-a",
1598 "assistant",
1599 2_100,
1600 4,
1601 json!({"type":"assistant","requestId":"req-1","message":{"id":"a-1","model":"test-model","content":[{"type":"text","text":"after compact"}],"usage":{"input_tokens":2,"cache_read_input_tokens":4,"cache_creation_input_tokens":6,"output_tokens":2}}}),
1602 ),
1603 make_record(
1604 "session-a",
1605 "user",
1606 2_200,
1607 5,
1608 json!({"type":"user","message":{"role":"user","content":"next prompt"}}),
1609 ),
1610 make_record(
1611 "session-a",
1612 "assistant",
1613 2_300,
1614 6,
1615 json!({"type":"assistant","requestId":"req-2","message":{"id":"a-2","model":"test-model","content":[{"type":"text","text":"next answer"}],"usage":{"input_tokens":2,"cache_read_input_tokens":10,"cache_creation_input_tokens":2,"output_tokens":2}}}),
1616 ),
1617 ],
1618 );
1619
1620 let no_prefix_error = write_streamed_request_trace_rows(
1621 &temp.path().join("no-prefix.jsonl"),
1622 &temp.path().join("no-prefix.sidecar.jsonl"),
1623 sessions.clone(),
1624 true,
1625 StubFactory::default(),
1626 ExportConfig {
1627 block_size: 16,
1628 delta_overlap_words: 50,
1629 tokenizer_workers: 1,
1630 },
1631 )
1632 .unwrap_err();
1633 assert!(
1634 no_prefix_error
1635 .to_string()
1636 .contains("no recoverable compaction prefix")
1637 );
1638
1639 let mut no_summary_write = sessions.clone();
1640 let first_post = no_summary_write
1641 .get_mut("session-a")
1642 .unwrap()
1643 .iter_mut()
1644 .find(|record| record.raw["requestId"] == "req-1")
1645 .unwrap();
1646 first_post.raw["message"]["usage"]["cache_creation_input_tokens"] = json!(0);
1647 let no_summary_write_error = write_streamed_request_trace_rows(
1648 &temp.path().join("no-summary-write.jsonl"),
1649 &temp.path().join("no-summary-write.sidecar.jsonl"),
1650 no_summary_write,
1651 true,
1652 StubFactory::default(),
1653 ExportConfig {
1654 block_size: 2,
1655 delta_overlap_words: 50,
1656 tokenizer_workers: 1,
1657 },
1658 )
1659 .unwrap_err();
1660 assert!(
1661 no_summary_write_error
1662 .to_string()
1663 .contains("post-compaction cache miss")
1664 );
1665
1666 let stats = write_streamed_request_trace_rows(
1667 &output_path,
1668 &sidecar_path,
1669 sessions,
1670 true,
1671 StubFactory::default(),
1672 ExportConfig {
1673 block_size: 2,
1674 delta_overlap_words: 50,
1675 tokenizer_workers: 1,
1676 },
1677 )
1678 .unwrap();
1679
1680 let rows = std::fs::read_to_string(&output_path)
1681 .unwrap()
1682 .lines()
1683 .map(|line| serde_json::from_str::<Value>(line).unwrap())
1684 .collect::<Vec<_>>();
1685 let sidecars = std::fs::read_to_string(&sidecar_path)
1686 .unwrap()
1687 .lines()
1688 .map(|line| serde_json::from_str::<Value>(line).unwrap())
1689 .collect::<Vec<_>>();
1690
1691 assert_eq!(stats.row_count, 4);
1692 assert_eq!(stats.sidecar_count, 4);
1693 assert_eq!(stats.fidelity.compactions_verified, 1);
1694 assert_eq!(stats.fidelity.compaction_prefix_blocks_verified, 4);
1695 assert_eq!(stats.fidelity.post_compaction_prefix_blocks_verified, 2);
1696 assert_eq!(rows.len(), 4);
1697 assert_eq!(sidecars.len(), 4);
1698 assert_eq!(
1699 rows[1]["event"]["request"]["request_id"],
1700 "claude:session-a:compact:0"
1701 );
1702 assert_eq!(rows[1]["event"]["request"]["request_received_ms"], 1_500);
1703 assert_eq!(rows[1]["event"]["event_time_unix_ms"], 2_000);
1704 assert_eq!(rows[1]["event"]["request"]["total_time_ms"], 500.0);
1705 assert!(rows[1]["event"]["request"].get("cached_tokens").is_none());
1706 assert_eq!(rows[1]["event"]["request"]["replay"]["input_length"], 10);
1707 assert_eq!(
1708 rows[1]["event"]["request"]["claude"]["compaction"]["pre_tokens"],
1709 10
1710 );
1711 assert_eq!(
1712 rows[1]["event"]["request"]["claude"]["compaction"]["post_tokens"],
1713 3
1714 );
1715
1716 let hashes = rows
1717 .iter()
1718 .map(|row| {
1719 row["event"]["request"]["replay"]["input_sequence_hashes"]
1720 .as_array()
1721 .unwrap()
1722 })
1723 .collect::<Vec<_>>();
1724 assert_eq!(hashes[0], &hashes[1][..4]);
1725 assert_eq!(&hashes[1][..2], &hashes[2][..2]);
1726 assert_ne!(hashes[1][2], hashes[2][2]);
1727 assert_eq!(&hashes[2][..5], &hashes[3][..5]);
1728 assert_ne!(hashes[2][5], hashes[3][5]);
1729
1730 let loaded = load_request_trace_records(&[output_path]).unwrap();
1731 assert_eq!(loaded.requests.len(), 4);
1732 let mut agentic_rows = Vec::new();
1733 lower_agentic_mooncake_rows(loaded, |_, row| {
1734 agentic_rows.push(row);
1735 Ok(())
1736 })
1737 .unwrap();
1738 assert_eq!(agentic_rows.len(), 4);
1739 assert_eq!(agentic_rows[1].request_id, "claude:session-a:compact:0");
1740 }
1741
1742 #[test]
1743 fn streamed_writer_emits_canonical_tool_terminal_events() {
1744 use dynamo_data_gen::request_trace::load::load_request_trace_records;
1745
1746 let temp = TempDir::new().unwrap();
1747 let output_path = temp.path().join("trace.jsonl");
1748 let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1749 let mut sessions = FxHashMap::default();
1750 sessions.insert(
1751 "session-a".to_string(),
1752 vec![
1753 make_record(
1754 "session-a",
1755 "user",
1756 1_000,
1757 0,
1758 json!({"type":"user","message":{"role":"user","content":"run"}}),
1759 ),
1760 make_record(
1761 "session-a",
1762 "assistant",
1763 1_100,
1764 1,
1765 json!({"type":"assistant","requestId":"req-1","message":{"id":"a-1","content":[{"type":"tool_use","id":"raw-1","name":"Bash","input":{}}],"usage":{"input_tokens":2,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"output_tokens":3}}}),
1766 ),
1767 make_record(
1768 "session-a",
1769 "user",
1770 1_200,
1771 2,
1772 json!({"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"raw-1","content":"bad","is_error":true}]}}),
1773 ),
1774 make_record(
1775 "session-a",
1776 "ai-title",
1777 0,
1778 3,
1779 json!({"type":"ai-title","aiTitle":"Background title"}),
1780 ),
1781 ],
1782 );
1783
1784 let stats = write_streamed_request_trace_rows(
1785 &output_path,
1786 &sidecar_path,
1787 sessions,
1788 true,
1789 StubFactory::default(),
1790 ExportConfig {
1791 block_size: 2,
1792 delta_overlap_words: 50,
1793 tokenizer_workers: 1,
1794 },
1795 )
1796 .unwrap();
1797
1798 assert_eq!(stats.row_count, 1);
1799 assert_eq!(stats.tool_row_count, 1);
1800 assert_eq!(stats.fidelity.requests_verified, 1);
1801 assert_eq!(stats.fidelity.tools_verified, 1);
1802 assert_eq!(stats.fidelity.background_titles_unreplayable, 1);
1803 let rows = std::fs::read_to_string(&output_path).unwrap();
1804 assert!(rows.lines().any(|line| {
1805 let row: Value = serde_json::from_str(line).unwrap();
1806 row["event"]["event_type"] == "tool_error"
1807 && row["event"]["tool"]["tool_class"] == "Bash"
1808 }));
1809 let loaded = load_request_trace_records(&[output_path]).unwrap();
1810 assert_eq!(loaded.tools.len(), 1);
1811 }
1812
1813 #[test]
1814 fn request_trace_preserves_child_identity_and_anonymized_causality() {
1815 use dynamo_data_gen::request_trace::{
1816 agentic::lower_agentic_mooncake_rows, load::load_request_trace_records,
1817 };
1818
1819 let temp = TempDir::new().unwrap();
1820 let output_path = temp.path().join("trace.jsonl");
1821 let sidecar_path = temp.path().join("trace.sidecar.jsonl");
1822 let mut sessions = FxHashMap::default();
1823 sessions.insert(
1824 "root-session".to_string(),
1825 vec![
1826 make_record(
1827 "root-session",
1828 "user",
1829 1_000,
1830 0,
1831 json!({"type":"user","message":{"role":"user","content":"spawn child"}}),
1832 ),
1833 make_record(
1834 "root-session",
1835 "assistant",
1836 1_100,
1837 1,
1838 json!({"type":"assistant","requestId":"root-1","message":{"id":"root-1","content":[{"type":"tool_use","id":"agent-call","name":"Agent","input":{"run_in_background":true}}],"usage":{"output_tokens":2}}}),
1839 ),
1840 make_record(
1841 "root-session",
1842 "user",
1843 1_150,
1844 2,
1845 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"}]}}),
1846 ),
1847 make_record(
1848 "root-session",
1849 "user",
1850 1_300,
1851 4,
1852 json!({"type":"user","message":{"role":"user","content":"continue parent work"}}),
1853 ),
1854 make_record(
1855 "root-session",
1856 "assistant",
1857 1_400,
1858 5,
1859 json!({"type":"assistant","requestId":"root-2","message":{"id":"root-2","content":[{"type":"text","text":"working"}],"usage":{"output_tokens":1}}}),
1860 ),
1861 make_record(
1862 "root-session",
1863 "queue-operation",
1864 1_800,
1865 6,
1866 json!({"type":"queue-operation","operation":"enqueue","content":"<tool-use-id>agent-call</tool-use-id><status>completed</status>done"}),
1867 ),
1868 make_record(
1869 "root-session",
1870 "user",
1871 1_850,
1872 7,
1873 json!({"type":"user","message":{"role":"user","content":"child done"}}),
1874 ),
1875 make_record(
1876 "root-session",
1877 "assistant",
1878 1_950,
1879 8,
1880 json!({"type":"assistant","requestId":"root-3","message":{"id":"root-3","content":[{"type":"text","text":"finished"}],"usage":{"output_tokens":1}}}),
1881 ),
1882 ],
1883 );
1884 sessions.insert(
1885 "child-agent".to_string(),
1886 vec![
1887 make_record(
1888 "root-session",
1889 "user",
1890 1_200,
1891 3,
1892 json!({"type":"user","isSidechain":true,"agentId":"child-agent","message":{"role":"user","content":"investigate"}}),
1893 ),
1894 make_record(
1895 "root-session",
1896 "assistant",
1897 1_700,
1898 9,
1899 json!({"type":"assistant","isSidechain":true,"agentId":"child-agent","message":{"id":"child-1","content":[{"type":"text","text":"result"}],"usage":{"output_tokens":1}}}),
1900 ),
1901 ],
1902 );
1903
1904 let config = ExportConfig {
1905 block_size: 2,
1906 delta_overlap_words: 50,
1907 tokenizer_workers: 2,
1908 };
1909 let stats = write_streamed_request_trace_rows(
1910 &output_path,
1911 &sidecar_path,
1912 sessions.clone(),
1913 true,
1914 StubFactory::default(),
1915 config,
1916 )
1917 .unwrap();
1918
1919 let anonymous_stats = write_streamed_request_trace_rows(
1920 &temp.path().join("anonymous.jsonl"),
1921 &temp.path().join("anonymous.sidecar.jsonl"),
1922 sessions,
1923 false,
1924 StubFactory::default(),
1925 config,
1926 )
1927 .unwrap();
1928 assert_eq!(anonymous_stats.fidelity.requests_verified, 4);
1929
1930 assert_eq!(stats.fidelity.requests_verified, 4);
1931 assert_eq!(stats.fidelity.tools_verified, 1);
1932 assert_eq!(stats.fidelity.child_links_verified, 1);
1933 assert_eq!(stats.fidelity.background_tools, 1);
1934 assert_eq!(stats.fidelity.background_agents, 1);
1935
1936 let rows = std::fs::read_to_string(&output_path)
1937 .unwrap()
1938 .lines()
1939 .map(|line| serde_json::from_str::<Value>(line).unwrap())
1940 .collect::<Vec<_>>();
1941 let child = rows
1942 .iter()
1943 .find(|row| row["event"]["agent_context"]["session_id"] == "child-agent")
1944 .unwrap();
1945 assert_eq!(
1946 child["event"]["agent_context"]["parent_session_id"],
1947 "root-session"
1948 );
1949 assert!(
1950 child["event"]["agent_context"]
1951 .get("session_final")
1952 .is_none()
1953 );
1954
1955 let loaded = load_request_trace_records(&[output_path]).unwrap();
1956 let mut agentic_rows = Vec::new();
1957 lower_agentic_mooncake_rows(loaded, |_, row| {
1958 agentic_rows.push(row);
1959 Ok(())
1960 })
1961 .unwrap();
1962 assert_eq!(agentic_rows.len(), 4);
1963 let by_id = agentic_rows
1964 .iter()
1965 .map(|row| (row.request_id.as_str(), row))
1966 .collect::<std::collections::HashMap<_, _>>();
1967 assert!(
1968 by_id["claude:child-agent:0"]
1969 .dependencies
1970 .iter()
1971 .any(|edge| {
1972 edge.request_id == "claude:root-session:0"
1973 && edge.relation == dynamo_data_gen::AgenticDependencyRelation::Spawn
1974 })
1975 );
1976 assert!(
1977 by_id["claude:root-session:1"]
1978 .dependencies
1979 .iter()
1980 .any(|edge| {
1981 edge.request_id == "claude:root-session:0"
1982 && edge.relation == dynamo_data_gen::AgenticDependencyRelation::Sequence
1983 })
1984 );
1985 assert!(
1986 !by_id["claude:root-session:2"]
1987 .dependencies
1988 .iter()
1989 .any(|edge| {
1990 edge.request_id == "claude:child-agent:0"
1991 && edge.relation == dynamo_data_gen::AgenticDependencyRelation::Join
1992 })
1993 );
1994 }
1995}