1use anyhow::{Context, Result};
2use std::collections::{HashMap, HashSet};
3use std::fmt;
4use std::fs::File;
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7
8use crate::data::models::{GlobalDataQuality, SessionData, SessionFile};
9use crate::data::scanner::{resolve_agent_parents, scan_claude_home};
10use crate::pricing::calculator::PricingCalculator;
11
12#[derive(Debug)]
15pub struct ValidationReport {
16 pub session_results: Vec<SessionValidation>,
17 pub structure_checks: Vec<Check>,
18 pub summary: ValidationSummary,
19}
20
21#[derive(Debug)]
22pub struct SessionValidation {
23 pub session_id: String,
24 pub project: String,
25 pub token_checks: Vec<Check>,
26 pub agent_checks: Vec<Check>,
27}
28
29#[derive(Debug)]
30pub struct Check {
31 pub name: String,
32 pub expected: String,
33 pub actual: String,
34 pub passed: bool,
35}
36
37impl Check {
38 fn pass(name: impl Into<String>, value: impl fmt::Display) -> Self {
39 let v = value.to_string();
40 Self {
41 name: name.into(),
42 expected: v.clone(),
43 actual: v,
44 passed: true,
45 }
46 }
47
48 fn compare(
49 name: impl Into<String>,
50 expected: impl fmt::Display,
51 actual: impl fmt::Display,
52 ) -> Self {
53 let e = expected.to_string();
54 let a = actual.to_string();
55 let passed = e == a;
56 Self {
57 name: name.into(),
58 expected: e,
59 actual: a,
60 passed,
61 }
62 }
63
64 #[allow(dead_code)]
65 fn compare_f64(name: impl Into<String>, expected: f64, actual: f64, tolerance: f64) -> Self {
66 let passed = (expected - actual).abs() < tolerance;
67 Self {
68 name: name.into(),
69 expected: format!("{:.2}", expected),
70 actual: format!("{:.2}", actual),
71 passed,
72 }
73 }
74}
75
76#[derive(Debug, Default)]
77pub struct ValidationSummary {
78 pub total_checks: usize,
79 pub passed: usize,
80 pub failed: usize,
81 pub sessions_validated: usize,
82 pub sessions_passed: usize,
83}
84
85#[derive(Debug, Default)]
90struct RawTokenCount {
91 input_tokens: u64,
92 output_tokens: u64,
93 cache_creation_tokens: u64,
94 cache_read_tokens: u64,
95 turn_count: usize,
96}
97
98fn is_valid_assistant(
105 val: &serde_json::Value,
106 skip_sidechain: bool,
107 now: &chrono::DateTime<chrono::Utc>,
108) -> bool {
109 if val.get("type").and_then(|t| t.as_str()) != Some("assistant") {
110 return false;
111 }
112 if skip_sidechain && val.get("isSidechain").and_then(|v| v.as_bool()) == Some(true) {
113 return false;
114 }
115 let model = val.pointer("/message/model").and_then(|m| m.as_str());
116 if model == Some("<synthetic>") || model.is_none() {
117 return false;
118 }
119 if val.pointer("/message/usage").is_none() {
121 return false;
122 }
123 let input = val
124 .pointer("/message/usage/input_tokens")
125 .and_then(|v| v.as_u64())
126 .unwrap_or(0);
127 let output = val
128 .pointer("/message/usage/output_tokens")
129 .and_then(|v| v.as_u64())
130 .unwrap_or(0);
131 let cache_creation = val
132 .pointer("/message/usage/cache_creation_input_tokens")
133 .and_then(|v| v.as_u64())
134 .unwrap_or(0);
135 let cache_read = val
136 .pointer("/message/usage/cache_read_input_tokens")
137 .and_then(|v| v.as_u64())
138 .unwrap_or(0);
139 if input + output + cache_creation + cache_read == 0 {
140 return false;
141 }
142 if let Some(ts_str) = val.get("timestamp").and_then(|t| t.as_str()) {
144 if let Ok(ts) = ts_str.parse::<chrono::DateTime<chrono::Utc>>() {
145 if ts > *now {
146 return false;
147 }
148 } else {
149 return false;
150 }
151 } else {
152 return false;
153 }
154 true
155}
156
157fn count_raw_tokens(path: &Path, skip_sidechain: bool) -> Result<RawTokenCount> {
160 let file = File::open(path)
161 .with_context(|| format!("raw counter: failed to open {}", path.display()))?;
162 let reader = BufReader::new(file);
163 let now = chrono::Utc::now();
164
165 let mut by_request: HashMap<String, (u64, u64, u64, u64)> = HashMap::new();
167 let mut no_request_id_count = RawTokenCount::default();
168
169 for line in reader.lines() {
170 let line = line?;
171 let val: serde_json::Value = match serde_json::from_str(&line) {
172 Ok(v) => v,
173 Err(_) => continue,
174 };
175
176 if !is_valid_assistant(&val, skip_sidechain, &now) {
177 continue;
178 }
179
180 let input = val
181 .pointer("/message/usage/input_tokens")
182 .and_then(|v| v.as_u64())
183 .unwrap_or(0);
184 let output = val
185 .pointer("/message/usage/output_tokens")
186 .and_then(|v| v.as_u64())
187 .unwrap_or(0);
188 let cache_creation = val
189 .pointer("/message/usage/cache_creation_input_tokens")
190 .and_then(|v| v.as_u64())
191 .unwrap_or(0);
192 let cache_read = val
193 .pointer("/message/usage/cache_read_input_tokens")
194 .and_then(|v| v.as_u64())
195 .unwrap_or(0);
196
197 let request_id = val.get("requestId").and_then(|r| r.as_str());
198
199 match request_id {
200 Some(rid) if !rid.is_empty() => {
201 by_request.insert(rid.to_string(), (input, output, cache_creation, cache_read));
202 }
203 _ => {
204 no_request_id_count.input_tokens += input;
205 no_request_id_count.output_tokens += output;
206 no_request_id_count.cache_creation_tokens += cache_creation;
207 no_request_id_count.cache_read_tokens += cache_read;
208 no_request_id_count.turn_count += 1;
209 }
210 }
211 }
212
213 let mut result = no_request_id_count;
214 for (input, output, cc, cr) in by_request.values() {
215 result.input_tokens += input;
216 result.output_tokens += output;
217 result.cache_creation_tokens += cc;
218 result.cache_read_tokens += cr;
219 result.turn_count += 1;
220 }
221
222 Ok(result)
223}
224
225fn count_tokens_by_request_id(
229 path: &Path,
230 skip_sidechain: bool,
231) -> Result<(HashMap<String, u64>, u64)> {
232 let file = File::open(path)?;
233 let reader = BufReader::new(file);
234 let now = chrono::Utc::now();
235 let mut by_rid: HashMap<String, u64> = HashMap::new();
236 let mut no_rid_output: u64 = 0;
237
238 for line in reader.lines() {
239 let line = line?;
240 let val: serde_json::Value = match serde_json::from_str(&line) {
241 Ok(v) => v,
242 Err(_) => continue,
243 };
244 if !is_valid_assistant(&val, skip_sidechain, &now) {
245 continue;
246 }
247 let output = val
248 .pointer("/message/usage/output_tokens")
249 .and_then(|v| v.as_u64())
250 .unwrap_or(0);
251 match val.get("requestId").and_then(|r| r.as_str()) {
252 Some(rid) if !rid.is_empty() => {
253 by_rid.insert(rid.to_string(), output);
254 }
255 _ => {
256 no_rid_output += output;
257 }
258 }
259 }
260 Ok((by_rid, no_rid_output))
261}
262
263fn collect_valid_request_ids(path: &Path, skip_sidechain: bool) -> Result<HashSet<String>> {
266 let file = File::open(path)?;
267 let reader = BufReader::new(file);
268 let now = chrono::Utc::now();
269 let mut ids = HashSet::new();
270
271 for line in reader.lines() {
272 let line = line?;
273 let val: serde_json::Value = match serde_json::from_str(&line) {
274 Ok(v) => v,
275 Err(_) => continue,
276 };
277 if !is_valid_assistant(&val, skip_sidechain, &now) {
278 continue;
279 }
280 if let Some(rid) = val.get("requestId").and_then(|r| r.as_str()) {
281 if !rid.is_empty() {
282 ids.insert(rid.to_string());
283 }
284 }
285 }
286 Ok(ids)
287}
288
289pub fn validate_all(
293 sessions: &[&SessionData],
294 quality: &GlobalDataQuality,
295 claude_home: &Path,
296 calc: &PricingCalculator,
297) -> Result<ValidationReport> {
298 let mut files = scan_claude_home(claude_home)?;
300 resolve_agent_parents(&mut files)?;
301
302 let (main_files, agent_files): (Vec<&SessionFile>, Vec<&SessionFile>) =
303 files.iter().partition(|f| !f.is_agent);
304
305 let mut structure_checks = Vec::new();
306 let mut session_results = Vec::new();
307
308 structure_checks.push(Check::compare(
312 "session_count == main_file_count",
313 main_files.len(),
314 quality.total_session_files,
315 ));
316
317 structure_checks.push(Check::compare(
319 "agent_file_count",
320 agent_files.len(),
321 quality.total_agent_files,
322 ));
323
324 let main_session_ids: HashSet<&str> =
326 main_files.iter().map(|f| f.session_id.as_str()).collect();
327 let orphan_count = agent_files
328 .iter()
329 .filter(|f| {
330 let parent = f.parent_session_id.as_deref().unwrap_or(&f.session_id);
331 !main_session_ids.contains(parent)
332 })
333 .count();
334 structure_checks.push(Check::pass(
335 format!("orphan_agents (no main session file): {}", orphan_count),
336 orphan_count,
337 ));
338
339 let unique_main_ids: HashSet<&str> = main_files.iter().map(|f| f.session_id.as_str()).collect();
341 let dup_count = main_files.len() - unique_main_ids.len();
342 structure_checks.push(Check::pass(
343 format!(
344 "main_session_files: {} files, {} unique IDs ({} duplicates)",
345 main_files.len(),
346 unique_main_ids.len(),
347 dup_count
348 ),
349 main_files.len(),
350 ));
351
352 let mut cross_file_overlap = 0usize;
354 for agent in &agent_files {
355 let parent_id = agent
356 .parent_session_id
357 .as_deref()
358 .unwrap_or(&agent.session_id);
359 let parent_file = main_files.iter().find(|f| f.session_id == parent_id);
360 if let Some(pf) = parent_file {
361 let parent_rids = collect_valid_request_ids(&pf.path, true).unwrap_or_default();
362 let agent_rids = collect_valid_request_ids(&agent.path, false).unwrap_or_default();
363 cross_file_overlap += parent_rids.intersection(&agent_rids).count();
364 }
365 }
366 structure_checks.push(Check::pass(
367 format!(
368 "cross_file_overlapping_request_ids (deduped: {})",
369 cross_file_overlap
370 ),
371 cross_file_overlap,
372 ));
373
374 let mut agents_by_parent: HashMap<&str, Vec<&SessionFile>> = HashMap::new();
378 for af in &agent_files {
379 let parent_id = af.parent_session_id.as_deref().unwrap_or(&af.session_id);
380 agents_by_parent.entry(parent_id).or_default().push(af);
381 }
382
383 let main_file_map: HashMap<&str, &SessionFile> = main_files
385 .iter()
386 .map(|f| (f.session_id.as_str(), *f))
387 .collect();
388
389 for session in sessions {
390 let mut token_checks = Vec::new();
391 let mut agent_checks = Vec::new();
392
393 if let Some(mf) = main_file_map.get(session.session_id.as_str()) {
395 let raw_main = count_raw_tokens(&mf.path, true).unwrap_or_default();
396
397 let pipeline_main_input: u64 = session
399 .turns
400 .iter()
401 .map(|t| t.usage.input_tokens.unwrap_or(0))
402 .sum();
403 let pipeline_main_output: u64 = session
404 .turns
405 .iter()
406 .map(|t| t.usage.output_tokens.unwrap_or(0))
407 .sum();
408 let pipeline_main_cache_creation: u64 = session
409 .turns
410 .iter()
411 .map(|t| t.usage.cache_creation_input_tokens.unwrap_or(0))
412 .sum();
413 let pipeline_main_cache_read: u64 = session
414 .turns
415 .iter()
416 .map(|t| t.usage.cache_read_input_tokens.unwrap_or(0))
417 .sum();
418 let pipeline_main_turns = session.turns.len();
419
420 token_checks.push(Check::compare(
421 "main_turn_count",
422 raw_main.turn_count,
423 pipeline_main_turns,
424 ));
425 token_checks.push(Check::compare(
426 "main_input_tokens",
427 raw_main.input_tokens,
428 pipeline_main_input,
429 ));
430 token_checks.push(Check::compare(
431 "main_output_tokens",
432 raw_main.output_tokens,
433 pipeline_main_output,
434 ));
435 token_checks.push(Check::compare(
436 "main_cache_creation_tokens",
437 raw_main.cache_creation_tokens,
438 pipeline_main_cache_creation,
439 ));
440 token_checks.push(Check::compare(
441 "main_cache_read_tokens",
442 raw_main.cache_read_tokens,
443 pipeline_main_cache_read,
444 ));
445 }
446
447 let agent_session_files = agents_by_parent.get(session.session_id.as_str());
449 let expected_agent_files = agent_session_files.map_or(0, |v| v.len());
450 let actual_agent_file_count = if expected_agent_files > 0 {
451 expected_agent_files
452 } else {
453 0
454 };
455
456 agent_checks.push(Check::compare(
457 "agent_file_count (from scanner)",
458 actual_agent_file_count,
459 expected_agent_files,
460 ));
461
462 if expected_agent_files > 0 {
464 if let Some(afs) = agent_session_files {
465 let main_file = main_file_map.get(session.session_id.as_str());
467 let main_rids = main_file
468 .map(|mf| collect_valid_request_ids(&mf.path, true).unwrap_or_default())
469 .unwrap_or_default();
470
471 let mut expected_unique_agent_turns = 0usize;
473 let mut raw_agent_output: u64 = 0;
474
475 for af in afs {
476 let raw = count_raw_tokens(&af.path, false).unwrap_or_default();
477 let file_rids = collect_valid_request_ids(&af.path, false).unwrap_or_default();
478 let file_overlap = file_rids.intersection(&main_rids).count();
479 let unique_turns = raw.turn_count.saturating_sub(file_overlap);
480 expected_unique_agent_turns += unique_turns;
481
482 let (per_rid, no_rid_output) =
484 count_tokens_by_request_id(&af.path, false).unwrap_or_default();
485 for (rid, output) in &per_rid {
486 if !main_rids.contains(rid) {
487 raw_agent_output += output;
488 }
489 }
490 raw_agent_output += no_rid_output;
491 }
492
493 let pipeline_subagent_turn_count = session.agent_turn_count();
495 agent_checks.push(Check::compare(
496 "agent_turn_count (after cross-file dedup)",
497 expected_unique_agent_turns,
498 pipeline_subagent_turn_count,
499 ));
500
501 if expected_unique_agent_turns > 0 {
503 agent_checks.push(Check::compare(
504 "has_agent_turns (non-overlapping exist)",
505 "true",
506 (pipeline_subagent_turn_count > 0).to_string(),
507 ));
508 }
509
510 let pipeline_agent_output: u64 = session
512 .subagents
513 .iter()
514 .flat_map(|s| s.turns.iter())
515 .map(|t| t.usage.output_tokens.unwrap_or(0))
516 .sum();
517
518 let agent_output_match = {
519 if raw_agent_output == 0 && pipeline_agent_output == 0 {
520 true
521 } else {
522 let max_val = raw_agent_output.max(pipeline_agent_output) as f64;
523 if max_val == 0.0 {
524 true
525 } else {
526 (raw_agent_output as f64 - pipeline_agent_output as f64).abs() / max_val
527 < 0.05
528 }
529 }
530 };
531
532 agent_checks.push(Check {
533 name: "agent_output_tokens (±5%)".into(),
534 expected: raw_agent_output.to_string(),
535 actual: pipeline_agent_output.to_string(),
536 passed: agent_output_match,
537 });
538
539 let all_marked_agent = session
541 .subagents
542 .iter()
543 .flat_map(|s| s.turns.iter())
544 .all(|t| t.is_agent);
545 agent_checks.push(Check::compare(
546 "all agent_turns have is_agent=true",
547 "true",
548 all_marked_agent.to_string(),
549 ));
550 }
551 }
552
553 let workflow_runs =
582 cc_session_jsonl::scanner::scan_session_workflows(&session.session_id, claude_home)
583 .unwrap_or_default();
584 for run in &workflow_runs {
585 let mut parsed_input: u64 = 0;
587 let mut parsed_output: u64 = 0;
588 let mut parsed_cache_creation: u64 = 0;
589 let mut parsed_cache_read: u64 = 0;
590 let mut parsed_agent_count = 0usize;
591 for sa in &session.subagents {
592 if sa.workflow_run_id.as_deref() != Some(run.run_id.as_str()) {
593 continue;
594 }
595 parsed_agent_count += 1;
596 for t in &sa.turns {
597 parsed_input += t.usage.input_tokens.unwrap_or(0);
598 parsed_output += t.usage.output_tokens.unwrap_or(0);
599 parsed_cache_creation += t.usage.cache_creation_input_tokens.unwrap_or(0);
600 parsed_cache_read += t.usage.cache_read_input_tokens.unwrap_or(0);
601 }
602 }
603 let parsed_total_tokens =
604 parsed_input + parsed_output + parsed_cache_creation + parsed_cache_read;
605
606 agent_checks.push(Check::compare(
608 format!("workflow[{}] parsed tokens > 0 (no loss)", run.run_id),
609 "true",
610 (parsed_total_tokens > 0).to_string(),
611 ));
612
613 if let Some(snap_agents) = run.snapshot.as_ref().and_then(|s| s.agent_count) {
615 agent_checks.push(Check::compare(
616 format!("workflow[{}] agent_count == snapshot", run.run_id),
617 snap_agents,
618 parsed_agent_count as u64,
619 ));
620 }
621
622 if let Some(snap_tokens) = run.snapshot.as_ref().and_then(|s| s.total_tokens) {
624 agent_checks.push(Check::pass(
625 format!(
626 "workflow[{}] snapshot.totalTokens={} | parsed total={} (in={} out={} cw={} cr={})",
627 run.run_id,
628 snap_tokens,
629 parsed_total_tokens,
630 parsed_input,
631 parsed_output,
632 parsed_cache_creation,
633 parsed_cache_read,
634 ),
635 parsed_total_tokens,
636 ));
637 }
638 }
639
640 let pipeline_total_output: u64 = session
642 .turns
643 .iter()
644 .chain(session.subagents.iter().flat_map(|s| s.turns.iter()))
645 .map(|t| t.usage.output_tokens.unwrap_or(0))
646 .sum();
647 let pipeline_total_turns = session.total_turn_count();
648
649 token_checks.push(Check::compare(
651 "total_turn_count == turns + agent_turns",
652 pipeline_total_turns,
653 session.all_responses().len(),
654 ));
655
656 if pipeline_total_turns > 0 {
658 token_checks.push(Check::compare(
659 "total_output_tokens > 0",
660 "true",
661 (pipeline_total_output > 0).to_string(),
662 ));
663 }
664
665 let pipeline_cost: f64 = session
667 .turns
668 .iter()
669 .chain(session.subagents.iter().flat_map(|s| s.turns.iter()))
670 .map(|t| calc.calculate_turn_cost(&t.model, &t.usage).total)
671 .sum();
672
673 let has_tokens = session
675 .turns
676 .iter()
677 .chain(session.subagents.iter().flat_map(|s| s.turns.iter()))
678 .any(|t| {
679 t.usage.input_tokens.unwrap_or(0) > 0 || t.usage.output_tokens.unwrap_or(0) > 0
680 });
681 if has_tokens {
682 token_checks.push(Check::compare(
683 "cost > 0 when tokens exist",
684 "true",
685 (pipeline_cost > 0.0).to_string(),
686 ));
687 }
688
689 if let Some(mf) = main_file_map.get(session.session_id.as_str()) {
691 token_checks.push(Check::compare(
692 "project_association",
693 mf.project.as_deref().unwrap_or("(none)"),
694 session.project.as_deref().unwrap_or("(none)"),
695 ));
696 }
697
698 let project_name = session
699 .project
700 .as_deref()
701 .unwrap_or("(unknown)")
702 .to_string();
703
704 session_results.push(SessionValidation {
705 session_id: session.session_id.clone(),
706 project: project_name,
707 token_checks,
708 agent_checks,
709 });
710 }
711
712 let mut summary = ValidationSummary::default();
715
716 for check in &structure_checks {
717 summary.total_checks += 1;
718 if check.passed {
719 summary.passed += 1;
720 } else {
721 summary.failed += 1;
722 }
723 }
724
725 for sv in &session_results {
726 summary.sessions_validated += 1;
727 let mut session_pass = true;
728 for check in sv.token_checks.iter().chain(sv.agent_checks.iter()) {
729 summary.total_checks += 1;
730 if check.passed {
731 summary.passed += 1;
732 } else {
733 summary.failed += 1;
734 session_pass = false;
735 }
736 }
737 if session_pass {
738 summary.sessions_passed += 1;
739 }
740 }
741
742 Ok(ValidationReport {
743 session_results,
744 structure_checks,
745 summary,
746 })
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752 use std::io::Write;
753 use tempfile::NamedTempFile;
754
755 fn make_assistant_line(request_id: &str, input: u64, output: u64) -> String {
756 format!(
757 r#"{{"type":"assistant","uuid":"u-{}","timestamp":"2026-03-16T10:00:00Z","message":{{"model":"claude-opus-4-6","role":"assistant","stop_reason":"end_turn","usage":{{"input_tokens":{},"output_tokens":{},"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"content":[{{"type":"text","text":"hi"}}]}},"sessionId":"s1","cwd":"/tmp","gitBranch":"","userType":"external","isSidechain":false,"parentUuid":null,"requestId":"{}"}}"#,
758 request_id, input, output, request_id
759 )
760 }
761
762 #[test]
763 fn raw_counter_basic() {
764 let mut f = NamedTempFile::new().unwrap();
765 writeln!(f, "{}", make_assistant_line("r1", 100, 50)).unwrap();
766 writeln!(f, "{}", make_assistant_line("r2", 200, 75)).unwrap();
767 f.flush().unwrap();
768
769 let result = count_raw_tokens(f.path(), true).unwrap();
770 assert_eq!(result.turn_count, 2);
771 assert_eq!(result.input_tokens, 300);
772 assert_eq!(result.output_tokens, 125);
773 }
774
775 #[test]
776 fn raw_counter_deduplicates_streaming() {
777 let mut f = NamedTempFile::new().unwrap();
778 writeln!(f, "{}", make_assistant_line("r1", 100, 50)).unwrap();
780 writeln!(f, "{}", make_assistant_line("r1", 200, 75)).unwrap();
781 f.flush().unwrap();
782
783 let result = count_raw_tokens(f.path(), true).unwrap();
784 assert_eq!(result.turn_count, 1);
785 assert_eq!(result.input_tokens, 200);
786 assert_eq!(result.output_tokens, 75);
787 }
788
789 #[test]
790 fn raw_counter_skips_synthetic() {
791 let mut f = NamedTempFile::new().unwrap();
792 writeln!(f, r#"{{"type":"assistant","uuid":"u1","timestamp":"2026-03-16T10:00:00Z","message":{{"model":"<synthetic>","role":"assistant","stop_reason":"end_turn","usage":{{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}},"content":[]}},"sessionId":"s1","cwd":"/tmp","gitBranch":"","userType":"external","isSidechain":false,"parentUuid":null,"requestId":"r1"}}"#).unwrap();
793 writeln!(f, "{}", make_assistant_line("r2", 200, 75)).unwrap();
794 f.flush().unwrap();
795
796 let result = count_raw_tokens(f.path(), true).unwrap();
797 assert_eq!(result.turn_count, 1);
798 assert_eq!(result.input_tokens, 200);
799 }
800
801 #[test]
802 fn raw_counter_respects_sidechain_flag() {
803 let sidechain_line = r#"{"type":"assistant","uuid":"u1","timestamp":"2026-03-16T10:00:00Z","message":{"model":"claude-opus-4-6","role":"assistant","stop_reason":"end_turn","usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0},"content":[]},"sessionId":"s1","cwd":"/tmp","gitBranch":"","userType":"external","isSidechain":true,"parentUuid":null,"requestId":"r1"}"#;
804 let mut f = NamedTempFile::new().unwrap();
805 writeln!(f, "{}", sidechain_line).unwrap();
806 f.flush().unwrap();
807
808 let result = count_raw_tokens(f.path(), true).unwrap();
810 assert_eq!(result.turn_count, 0);
811
812 let result = count_raw_tokens(f.path(), false).unwrap();
814 assert_eq!(result.turn_count, 1);
815 assert_eq!(result.input_tokens, 100);
816 }
817
818 #[test]
819 fn raw_counter_skips_non_assistant() {
820 let mut f = NamedTempFile::new().unwrap();
821 writeln!(f, r#"{{"type":"user","uuid":"u1","message":{{"role":"user","content":"hi"}},"timestamp":"2026-03-16T10:00:00Z","sessionId":"s1"}}"#).unwrap();
822 writeln!(f, r#"{{"type":"progress","data":{{"type":"hook"}},"uuid":"u2","timestamp":"2026-03-16T10:00:00Z","sessionId":"s1"}}"#).unwrap();
823 writeln!(f, "{}", make_assistant_line("r1", 100, 50)).unwrap();
824 f.flush().unwrap();
825
826 let result = count_raw_tokens(f.path(), true).unwrap();
827 assert_eq!(result.turn_count, 1);
828 }
829}