1use chrono::DateTime;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use super::activity::{classify, normalize_user_text, Activity};
10
11pub struct ModelPricing {
20 pub input: f64,
21 pub output: f64,
22 pub cache_read: f64,
23 pub cache_write: f64,
25 pub cache_write_1h: f64,
27}
28
29fn family_version(model: &str, family: &str) -> Option<(u32, u32)> {
39 fn is_date(p: &str) -> bool {
40 p.len() == 8 && p.bytes().all(|b| b.is_ascii_digit())
41 }
42 let rest = model.split_once(family)?.1.strip_prefix('-')?;
43 let mut parts = rest.split('-');
44 let head = parts.next()?;
45 if is_date(head) {
49 return None;
50 }
51 let major: u32 = head.parse().ok()?;
52 let minor = match parts.next() {
53 Some(p) if is_date(p) => 0,
54 Some(p) => p.parse().unwrap_or(0),
55 None => 0,
56 };
57 Some((major, minor))
58}
59
60pub fn pricing_for_model(model: &str) -> ModelPricing {
70 if model.contains("fable") {
71 let cache_read = match family_version(model, "fable") {
74 Some(v) if v >= (5, 1) => 0.25,
75 _ => 1.0,
76 };
77 ModelPricing {
78 input: 10.0,
79 output: 50.0,
80 cache_read,
81 cache_write: 12.5,
82 cache_write_1h: 20.0,
83 }
84 } else if model.contains("opus") {
85 ModelPricing {
86 input: 5.0,
87 output: 25.0,
88 cache_read: 0.5,
89 cache_write: 6.25,
90 cache_write_1h: 10.0,
91 }
92 } else if model.contains("haiku") {
93 ModelPricing {
94 input: 1.0,
95 output: 5.0,
96 cache_read: 0.1,
97 cache_write: 1.25,
98 cache_write_1h: 2.0,
99 }
100 } else if matches!(family_version(model, "sonnet"), Some(v) if v >= (5, 0)) {
101 ModelPricing {
102 input: 2.0,
103 output: 10.0,
104 cache_read: 0.2,
105 cache_write: 2.5,
106 cache_write_1h: 4.0,
107 }
108 } else {
109 ModelPricing {
111 input: 3.0,
112 output: 15.0,
113 cache_read: 0.3,
114 cache_write: 3.75,
115 cache_write_1h: 6.0,
116 }
117 }
118}
119
120pub const WEB_SEARCH_COST_PER_REQUEST: f64 = 0.01;
122
123pub fn fast_multiplier_for_model(model: &str) -> f64 {
135 if model.contains("opus-4-8") || model.contains("opus-5") {
136 2.0
137 } else if model.contains("opus-4-7") || model.contains("opus-4-6") {
138 6.0
139 } else {
140 1.0
141 }
142}
143
144pub fn cost_for_entry(entry: &UsageEntry) -> f64 {
152 let p = pricing_for_model(&entry.model);
153 let cw_1h = entry.cache_write_1h_tokens.min(entry.cache_write_tokens);
154 let cw_5m = entry.cache_write_tokens - cw_1h;
155 let token_cost = entry.input_tokens as f64 * p.input
156 + entry.output_tokens as f64 * p.output
157 + entry.cache_read_tokens as f64 * p.cache_read
158 + cw_5m as f64 * p.cache_write
159 + cw_1h as f64 * p.cache_write_1h;
160 let fast = if entry.speed.as_deref() == Some("fast") {
161 fast_multiplier_for_model(&entry.model)
162 } else {
163 1.0
164 };
165 (token_cost * fast) / 1_000_000.0
166 + entry.web_search_requests as f64 * WEB_SEARCH_COST_PER_REQUEST
167}
168
169pub fn context_window_for_model(model: &str) -> u64 {
170 if model.contains("[1m]") || model.contains("fable") {
172 1_000_000
173 } else {
174 200_000
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase")]
195pub struct UsageEntry {
196 pub session_id: String,
197 pub timestamp_ms: u64,
199 pub model: String,
200 pub input_tokens: u64,
201 pub output_tokens: u64,
202 pub cache_read_tokens: u64,
203 pub cache_write_tokens: u64,
208 #[serde(default)]
212 pub cache_write_1h_tokens: u64,
213 #[serde(default)]
216 pub web_search_requests: u64,
217 #[serde(default)]
221 pub speed: Option<String>,
222 pub tool_uses: Vec<String>,
226 pub text_chars: u32,
229 pub activity: Activity,
232 pub entrypoint: Option<String>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct HookExecution {
246 pub session_id: String,
247 pub timestamp_ms: u64,
248 pub hook_event: String,
250 pub hook_name: String,
252 pub command: String,
254 pub exit_code: i32,
255 pub duration_ms: u64,
256 pub tool_use_id: Option<String>,
258}
259
260#[derive(Default)]
262pub struct ParsedJsonl {
263 pub entries: Vec<UsageEntry>,
264 pub hooks: Vec<HookExecution>,
265}
266
267#[derive(Debug, Clone, Serialize, Default)]
269#[serde(rename_all = "camelCase")]
270pub struct SessionUsage {
271 pub model: String,
272 pub input_tokens: u64,
273 pub output_tokens: u64,
274 pub cache_read_tokens: u64,
275 pub cache_write_tokens: u64,
276 pub total_tokens: u64,
277 pub estimated_cost_usd: f64,
278 pub context_window_size: u64,
279 pub context_window_used_pct: f64,
280 pub current_context_tokens: u64,
282 pub total_context_sent: u64,
284 pub cache_hit_pct: f64,
286 pub message_count: u64,
287 pub session_id: String,
288}
289
290#[derive(Deserialize)]
292#[serde(rename_all = "camelCase")]
293struct JsonlRaw {
294 #[serde(rename = "type")]
295 entry_type: Option<String>,
296 timestamp: Option<String>,
297 message: Option<MessageData>,
298 attachment: Option<AttachmentData>,
299 subtype: Option<String>,
301 #[allow(dead_code)]
302 hook_count: Option<usize>,
303 hook_infos: Option<Vec<HookInfoEntry>>,
304 hook_errors: Option<Vec<serde_json::Value>>,
305 prevented_continuation: Option<bool>,
306 #[serde(rename = "toolUseID")]
307 tool_use_id: Option<String>,
308 entrypoint: Option<String>,
311}
312
313#[derive(Deserialize)]
314struct MessageData {
315 id: Option<String>,
316 model: Option<String>,
317 usage: Option<UsageData>,
318 #[serde(default)]
323 content: Option<serde_json::Value>,
324}
325
326#[derive(Deserialize, Default)]
327struct UsageData {
328 input_tokens: Option<u64>,
329 output_tokens: Option<u64>,
330 cache_read_input_tokens: Option<u64>,
331 cache_creation_input_tokens: Option<u64>,
334 cache_creation: Option<CacheCreation>,
336 server_tool_use: Option<ServerToolUse>,
338 speed: Option<String>,
340}
341
342#[derive(Deserialize, Default)]
344struct CacheCreation {
345 ephemeral_5m_input_tokens: Option<u64>,
346 ephemeral_1h_input_tokens: Option<u64>,
347}
348
349#[derive(Deserialize, Default)]
352struct ServerToolUse {
353 web_search_requests: Option<u64>,
354}
355
356#[derive(Deserialize)]
358#[serde(rename_all = "camelCase")]
359struct HookInfoEntry {
360 command: Option<String>,
361 duration_ms: Option<u64>,
362}
363
364#[derive(Deserialize)]
366#[serde(rename_all = "camelCase")]
367struct AttachmentData {
368 #[serde(rename = "type")]
369 attachment_type: Option<String>,
370 hook_name: Option<String>,
371 hook_event: Option<String>,
372 command: Option<String>,
373 exit_code: Option<i32>,
374 duration_ms: Option<u64>,
375 #[serde(rename = "toolUseID")]
376 tool_use_id: Option<String>,
377}
378
379#[derive(Deserialize)]
381#[serde(rename_all = "camelCase")]
382struct ActiveSession {
383 session_id: String,
384 cwd: String,
385 started_at: u64,
386}
387
388use super::MAX_JSONL_BYTES;
395
396pub fn encode_project_path(project_path: &str) -> String {
406 let normalized = project_path.replace('/', "\\");
407 let mut result = String::new();
408 let mut chars = normalized.chars().peekable();
409
410 while let Some(ch) = chars.next() {
411 if ch == ':' {
412 if chars.peek() == Some(&'\\') {
413 chars.next();
414 result.push_str("--");
415 } else {
416 result.push('-');
417 }
418 } else if ch == '\\' {
419 result.push('-');
420 } else {
421 result.push(ch);
422 }
423 }
424
425 while result.ends_with('-') {
426 result.pop();
427 }
428
429 result
430}
431
432struct AssistantContent {
434 tool_uses: Vec<String>,
435 bash_cmds: Vec<String>,
436 text_chars: u32,
437}
438
439fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
443 let mut out = AssistantContent {
444 tool_uses: Vec::new(),
445 bash_cmds: Vec::new(),
446 text_chars: 0,
447 };
448 let arr = match content.and_then(|v| v.as_array()) {
449 Some(a) => a,
450 None => return out,
451 };
452
453 for block in arr {
454 let block_type = match block.get("type").and_then(|v| v.as_str()) {
455 Some(t) => t,
456 None => continue,
457 };
458 match block_type {
459 "tool_use" => {
460 if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
461 if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
464 if let Some(cmd) = block
465 .get("input")
466 .and_then(|i| i.get("command"))
467 .and_then(|c| c.as_str())
468 {
469 out.bash_cmds.push(cmd.to_string());
470 }
471 }
472 out.tool_uses.push(name.to_string());
473 }
474 }
475 "text" => {
476 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
477 out.text_chars = out
480 .text_chars
481 .saturating_add(text.chars().count() as u32);
482 }
483 }
484 _ => {}
485 }
486 }
487
488 out
489}
490
491fn sanitize_hook_command_label(cmd: &str) -> String {
510 const MAX_LEN: usize = 64;
511 let first_line = cmd.lines().next().unwrap_or("").trim();
512 let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
513 if collapsed.chars().count() <= MAX_LEN {
514 return collapsed;
515 }
516 let truncated: String = collapsed.chars().take(MAX_LEN).collect();
517 format!("{truncated}…")
518}
519
520fn snake_to_pascal(s: &str) -> String {
522 s.split('_')
523 .filter(|w| !w.is_empty())
524 .map(|w| {
525 let mut c = w.chars();
526 match c.next() {
527 Some(first) => {
528 let mut out = first.to_uppercase().to_string();
529 out.extend(c);
530 out
531 }
532 None => String::new(),
533 }
534 })
535 .collect()
536}
537
538fn extract_user_text(content: &serde_json::Value) -> Option<String> {
541 match content {
542 serde_json::Value::String(s) => {
543 if s.is_empty() {
544 None
545 } else {
546 Some(s.clone())
547 }
548 }
549 serde_json::Value::Array(arr) => {
550 let mut parts: Vec<&str> = Vec::new();
551 for item in arr {
552 let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
553 if t == "text" {
554 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
555 if !text.is_empty() {
556 parts.push(text);
557 }
558 }
559 }
560 }
561 if parts.is_empty() {
562 None
563 } else {
564 Some(parts.join(" "))
565 }
566 }
567 _ => None,
568 }
569}
570
571struct EntryAccumulator {
574 out_idx: usize,
575 bash_cmds: Vec<String>,
576 user_text_at_first: Option<String>,
577}
578
579pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
603 let meta = fs::metadata(path)?;
604 if meta.len() > MAX_JSONL_BYTES {
605 tracing::warn!(
606 path = %path.display(),
607 size = meta.len(),
608 "skipping JSONL larger than {}MB",
609 MAX_JSONL_BYTES / 1024 / 1024
610 );
611 return Ok(ParsedJsonl::default());
612 }
613
614 let file = fs::File::open(path)?;
615 let reader = BufReader::new(file);
616
617 let session_id = path
618 .file_stem()
619 .and_then(|s| s.to_str())
620 .unwrap_or("")
621 .to_string();
622
623 let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
624 let mut out: Vec<UsageEntry> = Vec::new();
625 let mut hooks: Vec<HookExecution> = Vec::new();
626 let mut last_user_text: Option<String> = None;
627
628 for line in reader.lines() {
629 let line = match line {
630 Ok(l) => l,
631 Err(_) => continue,
632 };
633 let line = line.trim();
634 if line.is_empty() {
635 continue;
636 }
637
638 let raw: JsonlRaw = match serde_json::from_str(line) {
639 Ok(r) => r,
640 Err(_) => continue,
641 };
642
643 let entry_type = raw.entry_type.as_deref();
644
645 if entry_type == Some("user") {
648 if let Some(msg) = raw.message.as_ref() {
649 if let Some(content) = msg.content.as_ref() {
650 if let Some(text) = extract_user_text(content) {
651 last_user_text = Some(normalize_user_text(&text));
652 }
653 }
654 }
655 continue;
656 }
657
658 if entry_type == Some("attachment") {
660 if let Some(att) = raw.attachment {
661 if att.attachment_type.as_deref() == Some("hook_success") {
662 let timestamp_ms = raw
663 .timestamp
664 .as_deref()
665 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
666 .map(|dt| dt.timestamp_millis().max(0) as u64)
667 .unwrap_or(0);
668 hooks.push(HookExecution {
669 session_id: session_id.clone(),
670 timestamp_ms,
671 hook_event: att.hook_event.unwrap_or_default(),
672 hook_name: att.hook_name.unwrap_or_default(),
673 command: att.command.unwrap_or_default(),
674 exit_code: att.exit_code.unwrap_or(0),
675 duration_ms: att.duration_ms.unwrap_or(0),
676 tool_use_id: att.tool_use_id,
677 });
678 }
679 }
680 continue;
681 }
682
683 if entry_type == Some("system") {
686 if let Some(subtype) = raw.subtype.as_deref() {
687 if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
688 let hook_event = snake_to_pascal(event_snake);
689 let timestamp_ms = raw
690 .timestamp
691 .as_deref()
692 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
693 .map(|dt| dt.timestamp_millis().max(0) as u64)
694 .unwrap_or(0);
695 let had_errors = raw
696 .hook_errors
697 .as_ref()
698 .is_some_and(|e| !e.is_empty());
699 let prevented = raw.prevented_continuation.unwrap_or(false);
700 if let Some(infos) = raw.hook_infos {
701 for info in infos {
702 let cmd = info.command.unwrap_or_default();
703 let hook_name =
704 format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
705 hooks.push(HookExecution {
706 session_id: session_id.clone(),
707 timestamp_ms,
708 hook_event: hook_event.clone(),
709 hook_name,
710 command: cmd,
711 exit_code: if had_errors || prevented { 1 } else { 0 },
712 duration_ms: info.duration_ms.unwrap_or(0),
713 tool_use_id: raw.tool_use_id.clone(),
714 });
715 }
716 }
717 }
718 }
719 continue;
720 }
721
722 if entry_type != Some("assistant") {
723 continue;
724 }
725
726 let msg = match raw.message {
727 Some(m) => m,
728 None => continue,
729 };
730 let mid = match msg.id {
731 Some(id) => id,
732 None => continue,
733 };
734
735 let mut content_parsed = extract_assistant_content(msg.content.as_ref());
736
737 if let Some(acc) = accums.get_mut(&mid) {
740 let entry = &mut out[acc.out_idx];
741 entry
742 .tool_uses
743 .append(&mut content_parsed.tool_uses);
744 entry.text_chars = entry
745 .text_chars
746 .saturating_add(content_parsed.text_chars);
747 acc.bash_cmds.append(&mut content_parsed.bash_cmds);
748 continue;
749 }
750
751 let model = msg.model.unwrap_or_default();
757 if model == "<synthetic>" {
758 continue;
759 }
760
761 let usage = msg.usage.unwrap_or_default();
762 let timestamp_ms = raw
763 .timestamp
764 .as_deref()
765 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
766 .map(|dt| dt.timestamp_millis().max(0) as u64)
767 .unwrap_or(0);
768
769 let cw_flat = usage.cache_creation_input_tokens.unwrap_or(0);
773 let (eph_5m, eph_1h) = usage
774 .cache_creation
775 .as_ref()
776 .map(|c| {
777 (
778 c.ephemeral_5m_input_tokens.unwrap_or(0),
779 c.ephemeral_1h_input_tokens.unwrap_or(0),
780 )
781 })
782 .unwrap_or((0, 0));
783 let cache_write_tokens = cw_flat.max(eph_5m + eph_1h);
784 let cache_write_1h_tokens = eph_1h.min(cache_write_tokens);
785 let web_search_requests = usage
786 .server_tool_use
787 .as_ref()
788 .map(|s| s.web_search_requests.unwrap_or(0))
789 .unwrap_or(0);
790 let speed = usage.speed.clone();
791
792 accums.insert(
796 mid.clone(),
797 EntryAccumulator {
798 out_idx: out.len(),
799 bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
800 user_text_at_first: last_user_text.clone(),
801 },
802 );
803 out.push(UsageEntry {
804 session_id: session_id.clone(),
805 timestamp_ms,
806 model,
807 input_tokens: usage.input_tokens.unwrap_or(0),
808 output_tokens: usage.output_tokens.unwrap_or(0),
809 cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
810 cache_write_tokens,
811 cache_write_1h_tokens,
812 web_search_requests,
813 speed,
814 tool_uses: content_parsed.tool_uses,
815 text_chars: content_parsed.text_chars,
816 activity: Activity::General,
817 entrypoint: raw.entrypoint.clone(),
818 });
819 }
820
821 for acc in accums.values() {
824 let entry = &mut out[acc.out_idx];
825 let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
826 let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
827 entry.activity = classify(
828 &tool_refs,
829 &bash_refs,
830 acc.user_text_at_first.as_deref(),
831 entry.text_chars,
832 );
833 }
834
835 Ok(ParsedJsonl {
836 entries: out,
837 hooks,
838 })
839}
840
841pub fn find_current_session_jsonl(
845 config_dir: &str,
846 project_path: &str,
847 spawned_at: SystemTime,
848) -> Option<PathBuf> {
849 let config = Path::new(config_dir);
850 let encoded = encode_project_path(project_path);
851 let projects_dir = config.join("projects").join(&encoded);
852
853 if !projects_dir.exists() {
854 return None;
855 }
856
857 let sessions_dir = config.join("sessions");
860 if sessions_dir.exists() {
861 if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
862 let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
863 if jsonl_path.exists() {
864 return Some(jsonl_path);
865 }
866 }
867 }
868
869 let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
874
875 if let Ok(entries) = fs::read_dir(&projects_dir) {
876 for entry in entries.flatten() {
877 let path = entry.path();
878 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
879 continue;
880 }
881 if let Ok(meta) = path.metadata() {
882 let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
883 if created >= spawned_at {
884 candidates.push((path, created));
885 }
886 }
887 }
888 }
889
890 candidates.sort_by_key(|a| a.1);
892 candidates.into_iter().next().map(|(p, _)| p)
893}
894
895fn find_active_session_id(
898 sessions_dir: &Path,
899 project_path: &str,
900 spawned_at: SystemTime,
901) -> Option<String> {
902 let spawned_ms = spawned_at
903 .duration_since(SystemTime::UNIX_EPOCH)
904 .ok()?
905 .as_millis() as u64;
906
907 let threshold_ms = spawned_ms.saturating_sub(10_000);
909
910 let normalized_project = normalize_path(project_path);
911
912 let mut best: Option<(String, u64)> = None;
913
914 if let Ok(entries) = fs::read_dir(sessions_dir) {
915 for entry in entries.flatten() {
916 let path = entry.path();
917 if path.extension().and_then(|e| e.to_str()) != Some("json") {
918 continue;
919 }
920 if let Ok(content) = fs::read_to_string(&path) {
921 if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
922 let session_cwd = normalize_path(&session.cwd);
923 if session_cwd == normalized_project && session.started_at >= threshold_ms {
924 if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
930 best = Some((session.session_id, session.started_at));
931 }
932 }
933 }
934 }
935 }
936 }
937
938 best.map(|(id, _)| id)
939}
940
941fn normalize_path(p: &str) -> String {
943 p.replace('\\', "/")
944 .trim_end_matches('/')
945 .to_lowercase()
946}
947
948pub fn reduce_session(
957 entries: &[UsageEntry],
958 launched_model: &str,
959 session_id_hint: &str,
960) -> Option<SessionUsage> {
961 if entries.is_empty() {
962 return None;
963 }
964
965 let mut input_tokens = 0u64;
966 let mut output_tokens = 0u64;
967 let mut cache_read = 0u64;
968 let mut cache_write = 0u64;
969 let mut model_counts: HashMap<&str, u64> = HashMap::new();
970
971 for e in entries {
972 input_tokens += e.input_tokens;
973 output_tokens += e.output_tokens;
974 cache_read += e.cache_read_tokens;
975 cache_write += e.cache_write_tokens;
976 if !e.model.is_empty() {
977 *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
978 }
979 }
980
981 let primary_model = model_counts
982 .into_iter()
983 .max_by_key(|(_, c)| *c)
984 .map(|(m, _)| m.to_string())
985 .unwrap_or_else(|| {
986 entries
987 .last()
988 .map(|e| e.model.clone())
989 .unwrap_or_default()
990 });
991
992 let estimated_cost: f64 = entries.iter().map(cost_for_entry).sum();
996
997 let context_model = if launched_model.is_empty() {
998 &primary_model
999 } else {
1000 launched_model
1001 };
1002 let context_window = context_window_for_model(context_model);
1003
1004 let last = entries.last().unwrap();
1005 let last_context_used =
1006 last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
1007 let context_pct = if context_window > 0 {
1008 (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
1009 } else {
1010 0.0
1011 };
1012
1013 let total = input_tokens + output_tokens + cache_read + cache_write;
1014 let total_context_sent = input_tokens + cache_read + cache_write;
1015
1016 let cache_denom = input_tokens + cache_read + cache_write;
1021 let cache_hit_pct = if cache_denom > 0 {
1022 (cache_read as f64 / cache_denom as f64) * 100.0
1023 } else {
1024 0.0
1025 };
1026
1027 let session_id = entries
1028 .first()
1029 .map(|e| e.session_id.clone())
1030 .filter(|s| !s.is_empty())
1031 .unwrap_or_else(|| session_id_hint.to_string());
1032
1033 Some(SessionUsage {
1034 model: primary_model,
1035 input_tokens,
1036 output_tokens,
1037 cache_read_tokens: cache_read,
1038 cache_write_tokens: cache_write,
1039 total_tokens: total,
1040 estimated_cost_usd: estimated_cost,
1041 context_window_size: context_window,
1042 context_window_used_pct: context_pct,
1043 current_context_tokens: last_context_used,
1044 total_context_sent,
1045 cache_hit_pct,
1046 message_count: entries.len() as u64,
1047 session_id,
1048 })
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054 use std::io::Write;
1055 use tempfile::TempDir;
1056
1057 #[test]
1058 fn test_encode_project_path_drive_root() {
1059 assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
1060 }
1061
1062 #[test]
1063 fn test_encode_project_path_deep() {
1064 assert_eq!(
1065 encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
1066 "C--Users-Seven-Desktop-SF-project"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_encode_project_path_forward_slashes() {
1072 assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
1073 }
1074
1075 #[test]
1076 fn test_encode_project_path_trailing_slash() {
1077 assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
1078 }
1079
1080 #[test]
1081 fn test_encode_project_path_macos() {
1082 assert_eq!(
1083 encode_project_path("/Users/seven/Loom"),
1084 "-Users-seven-Loom"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_encode_project_path_linux() {
1090 assert_eq!(
1091 encode_project_path("/home/alice/projects/foo"),
1092 "-home-alice-projects-foo"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_encode_project_path_unix_trailing_slash() {
1098 assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
1099 }
1100
1101 #[test]
1102 fn test_normalize_path() {
1103 assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
1104 assert_eq!(normalize_path("S:/Loom"), "s:/loom");
1105 assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
1106 }
1107
1108 fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
1109 let tmp = TempDir::new().unwrap();
1110 let path = tmp.path().join("abc123.jsonl");
1111 let mut f = fs::File::create(&path).unwrap();
1112 for l in lines {
1113 writeln!(f, "{}", l).unwrap();
1114 }
1115 (tmp, path)
1116 }
1117
1118 fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
1119 format!(
1120 r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"{model}","usage":{{"input_tokens":{inp},"output_tokens":{out},"cache_read_input_tokens":{cr},"cache_creation_input_tokens":{cw}}}}}}}"#
1121 )
1122 }
1123
1124 #[test]
1125 fn parse_deduplicates_repeated_message_ids() {
1126 let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
1129 let a1_dup = a1.clone();
1130 let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
1131 let a2_dup = a2.clone();
1132 let a2_dup2 = a2.clone();
1133 let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
1134
1135 let entries = parse_jsonl_entries(&path).unwrap().entries;
1136 assert_eq!(entries.len(), 2, "should dedup by message.id");
1137 assert_eq!(entries[0].input_tokens, 15);
1138 assert_eq!(entries[0].output_tokens, 1463);
1139 assert_eq!(entries[1].input_tokens, 10);
1140 }
1141
1142 #[test]
1143 fn parse_captures_entrypoint_from_assistant_row() {
1144 let headless = r#"{"type":"assistant","timestamp":"2026-05-13T07:19:12.573Z","entrypoint":"sdk-cli","message":{"id":"msg_h1","model":"claude-opus-4-7","usage":{"input_tokens":5,"output_tokens":628,"cache_read_input_tokens":0,"cache_creation_input_tokens":14266}}}"#;
1148 let interactive = r#"{"type":"assistant","timestamp":"2026-05-13T08:00:00.000Z","entrypoint":"cli","message":{"id":"msg_i1","model":"claude-opus-4-6","usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1149 let legacy = r#"{"type":"assistant","timestamp":"2026-05-13T09:00:00.000Z","message":{"id":"msg_l1","model":"claude-sonnet-4-6","usage":{"input_tokens":3,"output_tokens":4,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1150 let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
1151
1152 let entries = parse_jsonl_entries(&path).unwrap().entries;
1153 assert_eq!(entries.len(), 3);
1154 assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
1155 assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
1156 assert_eq!(entries[2].entrypoint, None);
1157 }
1158
1159 #[test]
1160 fn parse_skips_non_assistant_entries() {
1161 let lines = [
1162 r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
1163 r#"{"type":"permission-mode"}"#.to_string(),
1164 r#"{"type":"file-history-snapshot"}"#.to_string(),
1165 r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
1166 assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
1167 ];
1168 let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
1169 let (_tmp, path) = write_jsonl(&line_refs);
1170
1171 let entries = parse_jsonl_entries(&path).unwrap().entries;
1172 assert_eq!(entries.len(), 1);
1173 assert_eq!(entries[0].model, "claude-sonnet-4-6");
1174 }
1175
1176 #[test]
1177 fn parse_tolerates_malformed_lines_and_empty_lines() {
1178 let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1179 let lines = vec![
1180 "",
1181 "not json at all",
1182 r#"{"type":"assistant","message":null}"#,
1183 r#"{"type":"assistant","message":{"id":null}}"#,
1184 &good,
1185 " ",
1186 r#"{"broken":"trailing"#,
1187 ];
1188 let (_tmp, path) = write_jsonl(&lines);
1189
1190 let entries = parse_jsonl_entries(&path).unwrap().entries;
1191 assert_eq!(entries.len(), 1);
1192 }
1193
1194 #[test]
1195 fn parse_extracts_session_id_from_file_stem() {
1196 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1197 let (_tmp, path) = write_jsonl(&[&line]);
1198 let entries = parse_jsonl_entries(&path).unwrap().entries;
1199 assert_eq!(entries[0].session_id, "abc123");
1200 }
1201
1202 #[test]
1203 fn parse_parses_iso_timestamps() {
1204 let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1205 let (_tmp, path) = write_jsonl(&[&line]);
1206 let entries = parse_jsonl_entries(&path).unwrap().entries;
1207 assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1209 }
1210
1211 #[test]
1212 fn parse_skips_synthetic_model_rows() {
1213 let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1216 let synthetic = r#"{"type":"assistant","timestamp":"2026-04-11T00:27:39.000Z","message":{"id":"msg_synth","model":"<synthetic>","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
1217 let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1218 let entries = parse_jsonl_entries(&path).unwrap().entries;
1219 assert_eq!(entries.len(), 1);
1220 assert_eq!(entries[0].model, "claude-opus-4-6");
1221 }
1222
1223 #[test]
1224 fn parse_handles_missing_usage_fields() {
1225 let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1226 let (_tmp, path) = write_jsonl(&[line]);
1227 let entries = parse_jsonl_entries(&path).unwrap().entries;
1228 assert_eq!(entries.len(), 1);
1229 assert_eq!(entries[0].input_tokens, 0);
1230 assert_eq!(entries[0].cache_read_tokens, 0);
1231 }
1232
1233 #[test]
1234 fn reduce_computes_primary_model_by_mode() {
1235 let e = |m: &str| UsageEntry {
1236 session_id: "s".into(),
1237 timestamp_ms: 0,
1238 model: m.into(),
1239 input_tokens: 1,
1240 output_tokens: 1,
1241 cache_read_tokens: 0,
1242 cache_write_tokens: 0,
1243 cache_write_1h_tokens: 0,
1244 web_search_requests: 0,
1245 speed: None,
1246 tool_uses: Vec::new(),
1247 text_chars: 0,
1248 activity: Activity::General,
1249 entrypoint: None,
1250 };
1251 let entries = vec![
1252 e("claude-opus-4-6"),
1253 e("claude-opus-4-6"),
1254 e("claude-opus-4-6"),
1255 e("claude-sonnet-4-6"),
1256 ];
1257 let u = reduce_session(&entries, "", "sid").unwrap();
1258 assert_eq!(u.model, "claude-opus-4-6");
1259 assert_eq!(u.message_count, 4);
1260 }
1261
1262 #[test]
1263 fn reduce_uses_launched_model_for_context_window() {
1264 let entries = vec![UsageEntry {
1265 session_id: "s".into(),
1266 timestamp_ms: 0,
1267 model: "claude-opus-4-6".into(),
1269 input_tokens: 1000,
1270 output_tokens: 0,
1271 cache_read_tokens: 0,
1272 cache_write_tokens: 0,
1273 cache_write_1h_tokens: 0,
1274 web_search_requests: 0,
1275 speed: None,
1276 tool_uses: Vec::new(),
1277 text_chars: 0,
1278 activity: Activity::General,
1279 entrypoint: None,
1280 }];
1281 let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1282 let without = reduce_session(&entries, "", "sid").unwrap();
1283 assert_eq!(with_1m.context_window_size, 1_000_000);
1284 assert_eq!(without.context_window_size, 200_000);
1285 }
1286
1287 #[test]
1288 fn reduce_context_pct_uses_last_entry_only() {
1289 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1291 session_id: "s".into(),
1292 timestamp_ms: 0,
1293 model: "claude-opus-4-6".into(),
1294 input_tokens: input,
1295 output_tokens: 0,
1296 cache_read_tokens: cr,
1297 cache_write_tokens: cw,
1298 cache_write_1h_tokens: 0,
1299 web_search_requests: 0,
1300 speed: None,
1301 tool_uses: Vec::new(),
1302 text_chars: 0,
1303 activity: Activity::General,
1304 entrypoint: None,
1305 };
1306 let entries = vec![
1307 mk(1000, 0, 0),
1308 mk(500, 0, 0),
1309 mk(5, 160_000, 0), ];
1311 let u = reduce_session(&entries, "", "sid").unwrap();
1312 assert_eq!(u.current_context_tokens, 160_005);
1313 assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1314 }
1315
1316 #[test]
1317 fn reduce_cache_hit_pct() {
1318 let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1319 session_id: "s".into(),
1320 timestamp_ms: 0,
1321 model: "claude-opus-4-6".into(),
1322 input_tokens: input,
1323 output_tokens: 1,
1324 cache_read_tokens: cr,
1325 cache_write_tokens: cw,
1326 cache_write_1h_tokens: 0,
1327 web_search_requests: 0,
1328 speed: None,
1329 tool_uses: Vec::new(),
1330 text_chars: 0,
1331 activity: Activity::General,
1332 entrypoint: None,
1333 };
1334 let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1336 assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1337
1338 let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1340 assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1341
1342 let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1344 assert_eq!(u.cache_hit_pct, 0.0);
1345 }
1346
1347 #[test]
1348 fn reduce_total_context_sent_is_cumulative() {
1349 let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1350 session_id: "s".into(),
1351 timestamp_ms: 0,
1352 model: "claude-opus-4-6".into(),
1353 input_tokens: input,
1354 output_tokens: 0,
1355 cache_read_tokens: cr,
1356 cache_write_tokens: cw,
1357 cache_write_1h_tokens: 0,
1358 web_search_requests: 0,
1359 speed: None,
1360 tool_uses: Vec::new(),
1361 text_chars: 0,
1362 activity: Activity::General,
1363 entrypoint: None,
1364 };
1365 let u = reduce_session(
1366 &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1367 "",
1368 "sid",
1369 )
1370 .unwrap();
1371 assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1372 }
1373
1374 #[test]
1375 fn reduce_cost_matches_opus_pricing() {
1376 let entries = vec![UsageEntry {
1378 session_id: "s".into(),
1379 timestamp_ms: 0,
1380 model: "claude-opus-4-6".into(),
1381 input_tokens: 1_000_000, output_tokens: 1_000_000, cache_read_tokens: 1_000_000, cache_write_tokens: 1_000_000, cache_write_1h_tokens: 0,
1386 web_search_requests: 0,
1387 speed: None,
1388 tool_uses: Vec::new(),
1389 text_chars: 0,
1390 activity: Activity::General,
1391 entrypoint: None,
1392 }];
1393 let u = reduce_session(&entries, "", "sid").unwrap();
1394 assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1395 }
1396
1397 #[test]
1398 fn reduce_empty_returns_none() {
1399 assert!(reduce_session(&[], "", "sid").is_none());
1400 }
1401
1402 fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1405 format!(
1406 r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"claude-opus-4-6","usage":{{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"content":{content_json}}}}}"#
1407 )
1408 }
1409
1410 #[test]
1411 fn parse_extracts_tool_use_names() {
1412 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"a.rs"}},{"type":"tool_use","id":"tu_2","name":"Read","input":{"file_path":"b.rs"}}]"#;
1413 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1414 let (_tmp, path) = write_jsonl(&[&line]);
1415 let entries = parse_jsonl_entries(&path).unwrap().entries;
1416 assert_eq!(entries.len(), 1);
1417 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1418 }
1419
1420 #[test]
1421 fn parse_captures_text_char_count() {
1422 let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1423 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1424 let (_tmp, path) = write_jsonl(&[&line]);
1425 let entries = parse_jsonl_entries(&path).unwrap().entries;
1426 assert_eq!(entries[0].text_chars, 12); }
1428
1429 #[test]
1430 fn parse_handles_missing_content_gracefully() {
1431 let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1434 let (_tmp, path) = write_jsonl(&[&line]);
1435 let entries = parse_jsonl_entries(&path).unwrap().entries;
1436 assert_eq!(entries.len(), 1);
1437 assert!(entries[0].tool_uses.is_empty());
1438 assert_eq!(entries[0].text_chars, 0);
1439 assert_eq!(entries[0].activity, Activity::General);
1440 }
1441
1442 #[test]
1443 fn parse_classifies_edit_plus_debug_as_debugging() {
1444 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"Please fix the bug in login.rs"}}"#.to_string();
1446 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1447 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1448 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1449 let entries = parse_jsonl_entries(&path).unwrap().entries;
1450 assert_eq!(entries.len(), 1);
1451 assert_eq!(entries[0].activity, Activity::Debugging);
1452 }
1453
1454 #[test]
1455 fn parse_classifies_bash_pytest_as_testing() {
1456 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1457 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1458 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1459 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1460 let entries = parse_jsonl_entries(&path).unwrap().entries;
1461 assert_eq!(entries[0].activity, Activity::Testing);
1462 }
1463
1464 #[test]
1465 fn parse_classifies_bash_git_as_gitops() {
1466 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1467 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1468 let (_tmp, path) = write_jsonl(&[&line]);
1469 let entries = parse_jsonl_entries(&path).unwrap().entries;
1470 assert_eq!(entries[0].activity, Activity::GitOps);
1471 }
1472
1473 #[test]
1474 fn parse_tool_result_row_does_not_overwrite_user_text() {
1475 let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1480 let asst1 = assistant_with_content(
1481 "msg_1",
1482 "2026-04-14T08:27:35.000Z",
1483 r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1484 );
1485 let tool_result = r#"{"type":"user","timestamp":"2026-04-14T08:27:36.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu_1","content":"file contents..."}]}}"#.to_string();
1486 let asst2 = assistant_with_content(
1487 "msg_2",
1488 "2026-04-14T08:27:40.000Z",
1489 r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1490 );
1491 let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1492 let entries = parse_jsonl_entries(&path).unwrap().entries;
1493 assert_eq!(entries.len(), 2);
1494 assert_eq!(entries[0].activity, Activity::Exploration);
1496 assert_eq!(entries[1].activity, Activity::Refactor);
1499 }
1500
1501 #[test]
1502 fn parse_classifies_agent_turn_as_delegation() {
1503 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1504 let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1505 let (_tmp, path) = write_jsonl(&[&line]);
1506 let entries = parse_jsonl_entries(&path).unwrap().entries;
1507 assert_eq!(entries[0].activity, Activity::Delegation);
1508 }
1509
1510 #[test]
1511 fn parse_pure_text_turn_is_conversation() {
1512 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1513 let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1514 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1515 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1516 let entries = parse_jsonl_entries(&path).unwrap().entries;
1517 assert_eq!(entries[0].activity, Activity::Conversation);
1518 assert!(entries[0].text_chars > 0);
1519 }
1520
1521 #[test]
1522 fn parse_user_array_content_extracts_text_blocks() {
1523 let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":[{"type":"text","text":"fix the crash"}]}}"#.to_string();
1526 let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
1527 let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1528 let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1529 let entries = parse_jsonl_entries(&path).unwrap().entries;
1530 assert_eq!(entries[0].activity, Activity::Debugging);
1531 }
1532
1533 fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1539 format!(
1540 r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"claude-opus-4-7","usage":{{"input_tokens":6,"output_tokens":746,"cache_read_input_tokens":21859,"cache_creation_input_tokens":33027}},"content":{content_json}}}}}"#
1541 )
1542 }
1543
1544 #[test]
1545 fn parse_merges_tool_uses_across_split_content_rows() {
1546 let thinking = assistant_split_row(
1552 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1553 "2026-05-13T09:09:03.500Z",
1554 r#"[{"type":"thinking","thinking":"plan"}]"#,
1555 );
1556 let text = assistant_split_row(
1557 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1558 "2026-05-13T09:09:03.550Z",
1559 r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1560 );
1561 let tu1 = assistant_split_row(
1562 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1563 "2026-05-13T09:09:03.600Z",
1564 r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1565 );
1566 let tu2 = assistant_split_row(
1567 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1568 "2026-05-13T09:09:03.700Z",
1569 r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1570 );
1571 let tu3 = assistant_split_row(
1572 "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1573 "2026-05-13T09:09:03.800Z",
1574 r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1575 );
1576 let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1577
1578 let entries = parse_jsonl_entries(&path).unwrap().entries;
1579 assert_eq!(entries.len(), 1, "still one entry per message.id");
1580
1581 let e = &entries[0];
1582 assert_eq!(
1583 e.tool_uses,
1584 vec![
1585 "mcp__example__list_configs".to_string(),
1586 "mcp__example__get_report".to_string(),
1587 "Edit".to_string(),
1588 ],
1589 "all tool_use names merged in row order"
1590 );
1591 assert!(e.text_chars > 0, "text from the text row is preserved");
1592
1593 assert_eq!(e.input_tokens, 6);
1597 assert_eq!(e.output_tokens, 746);
1598 assert_eq!(e.cache_read_tokens, 21859);
1599 assert_eq!(e.cache_write_tokens, 33027);
1600 }
1601
1602 #[test]
1603 fn parse_split_row_classifies_against_merged_content() {
1604 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"please fix the bug in auth.rs"}}"#.to_string();
1609 let thinking = assistant_split_row(
1610 "msg_split",
1611 "2026-05-13T09:09:01.000Z",
1612 r#"[{"type":"thinking","thinking":"investigating"}]"#,
1613 );
1614 let edit = assistant_split_row(
1615 "msg_split",
1616 "2026-05-13T09:09:02.000Z",
1617 r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1618 );
1619 let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1620
1621 let entries = parse_jsonl_entries(&path).unwrap().entries;
1622 assert_eq!(entries.len(), 1);
1623 assert_eq!(entries[0].activity, Activity::Debugging);
1624 assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1625 }
1626
1627 #[test]
1628 fn parse_split_row_bash_commands_feed_classifier() {
1629 let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1632 let text = assistant_split_row(
1633 "msg_bash",
1634 "2026-05-13T09:09:01.000Z",
1635 r#"[{"type":"text","text":"Running the unit tests."}]"#,
1636 );
1637 let bash = assistant_split_row(
1638 "msg_bash",
1639 "2026-05-13T09:09:02.000Z",
1640 r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1641 );
1642 let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1643
1644 let entries = parse_jsonl_entries(&path).unwrap().entries;
1645 assert_eq!(entries[0].activity, Activity::Testing);
1646 }
1647
1648 #[test]
1651 fn parse_extracts_hook_success_events() {
1652 let hook_line = r#"{"type":"attachment","timestamp":"2026-04-15T18:08:50.000Z","attachment":{"type":"hook_success","hookName":"PostToolUse:Edit","hookEvent":"PostToolUse","toolUseID":"tu_abc","command":"verify-edit.sh","exitCode":0,"durationMs":90}}"#;
1653 let hook_fail = r#"{"type":"attachment","timestamp":"2026-04-15T18:09:00.000Z","attachment":{"type":"hook_success","hookName":"PreToolUse:Bash","hookEvent":"PreToolUse","toolUseID":"tu_def","command":"lint-check.sh","exitCode":1,"durationMs":250}}"#;
1654 let hook_msg = r#"{"type":"attachment","timestamp":"2026-04-15T18:09:01.000Z","attachment":{"type":"hook_system_message","hookName":"Stop","hookEvent":"Stop","content":"reminder text"}}"#;
1656 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1657 let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1658
1659 let parsed = parse_jsonl_entries(&path).unwrap();
1660 assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1661 assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1662
1663 assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1664 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1665 assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1666 assert_eq!(parsed.hooks[0].exit_code, 0);
1667 assert_eq!(parsed.hooks[0].duration_ms, 90);
1668
1669 assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1670
1671 assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1672 assert_eq!(parsed.hooks[1].exit_code, 1);
1673 assert_eq!(parsed.hooks[1].duration_ms, 250);
1674 assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1675 }
1676
1677 #[test]
1678 fn parse_ignores_attachment_without_hook_data() {
1679 let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1681 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1682 let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1683
1684 let parsed = parse_jsonl_entries(&path).unwrap();
1685 assert_eq!(parsed.entries.len(), 1);
1686 assert!(parsed.hooks.is_empty());
1687 }
1688
1689 #[test]
1690 fn snake_to_pascal_converts_correctly() {
1691 assert_eq!(snake_to_pascal("stop"), "Stop");
1692 assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1693 assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1694 assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1695 }
1696
1697 #[test]
1698 fn parse_extracts_system_hook_summary_v2() {
1699 let stop_summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"Running build checks...","durationMs":5227}],"hookErrors":[],"preventedContinuation":false,"stopReason":"","hasOutput":false,"level":"suggestion","timestamp":"2026-04-25T09:06:12.854Z","uuid":"abc","toolUseID":"tu_xyz","sessionId":"sess-1","version":"2.1.119"}"#;
1701 let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1702 let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1703
1704 let parsed = parse_jsonl_entries(&path).unwrap();
1705 assert_eq!(parsed.entries.len(), 1);
1706 assert_eq!(parsed.hooks.len(), 1);
1707
1708 let h = &parsed.hooks[0];
1709 assert_eq!(h.hook_event, "Stop");
1710 assert_eq!(h.command, "Running build checks...");
1711 assert_eq!(h.duration_ms, 5227);
1712 assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1713 assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1714 }
1715
1716 #[test]
1717 fn parse_system_hook_summary_with_errors() {
1718 let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":3000}],"hookErrors":["something broke"],"preventedContinuation":true,"timestamp":"2026-04-25T10:00:00.000Z","uuid":"def","toolUseID":"tu_err","sessionId":"sess-2","version":"2.1.119"}"#;
1719 let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1720 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1721
1722 let parsed = parse_jsonl_entries(&path).unwrap();
1723 assert_eq!(parsed.hooks.len(), 1);
1724 assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1725 }
1726
1727 #[test]
1728 fn parse_system_hook_summary_multiple_hooks() {
1729 let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":2,"hookInfos":[{"command":"tsc --noEmit","durationMs":1200},{"command":"cargo check","durationMs":3500}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T11:00:00.000Z","uuid":"ghi","toolUseID":"tu_multi","sessionId":"sess-3","version":"2.1.119"}"#;
1730 let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1731 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1732
1733 let parsed = parse_jsonl_entries(&path).unwrap();
1734 assert_eq!(parsed.hooks.len(), 2);
1735 assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1736 assert_eq!(parsed.hooks[0].duration_ms, 1200);
1737 assert_eq!(parsed.hooks[1].command, "cargo check");
1738 assert_eq!(parsed.hooks[1].duration_ms, 3500);
1739 assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1740 }
1741
1742 #[test]
1743 fn hook_name_label_sanitizes_long_multiline_commands() {
1744 let long_cmd = "Goal statement: Achieve >=3x better compression ratio than zstd -19 --long=27 and xz -9e on the Lichess 2024-01 corpus.\n\n3. Validation strategy:\n- (a) Fixed + held-out corpora.";
1748 let escaped = serde_json::to_string(long_cmd).unwrap();
1749 let summary = format!(
1750 r#"{{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{{"command":{escaped},"durationMs":42}}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T12:00:00.000Z","uuid":"goal","toolUseID":"tu_goal","sessionId":"sess-goal","version":"2.1.119"}}"#
1751 );
1752 let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1753 let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1754
1755 let parsed = parse_jsonl_entries(&path).unwrap();
1756 assert_eq!(parsed.hooks.len(), 1);
1757 let h = &parsed.hooks[0];
1758
1759 assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1760 assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1761 assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1763 assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1764 assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1765 }
1766
1767 #[test]
1768 fn hook_name_label_preserves_short_commands() {
1769 let summary = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":3000}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T13:00:00.000Z","uuid":"short","toolUseID":"tu_short","sessionId":"sess-short","version":"2.1.119"}"#;
1770 let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13:00:01.000Z", 1, 1, 0, 0);
1771 let (_tmp, path) = write_jsonl(&[summary, &asst]);
1772
1773 let parsed = parse_jsonl_entries(&path).unwrap();
1774 assert_eq!(parsed.hooks[0].hook_name, "Stop:cargo check");
1775 }
1776
1777 #[test]
1778 fn parse_handles_both_formats_in_same_file() {
1779 let old_hook = r#"{"type":"attachment","timestamp":"2026-04-23T14:05:05.937Z","attachment":{"type":"hook_success","hookName":"PostToolUse:Edit","hookEvent":"PostToolUse","toolUseID":"tu_old","command":"lint.sh","exitCode":0,"durationMs":90}}"#;
1781 let new_hook = r#"{"type":"system","subtype":"stop_hook_summary","hookCount":1,"hookInfos":[{"command":"cargo check","durationMs":5000}],"hookErrors":[],"preventedContinuation":false,"timestamp":"2026-04-25T09:06:12.854Z","uuid":"both","toolUseID":"tu_new","sessionId":"sess-both","version":"2.1.119"}"#;
1783 let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1784 let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1785
1786 let parsed = parse_jsonl_entries(&path).unwrap();
1787 assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1788 assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1789 assert_eq!(parsed.hooks[1].hook_event, "Stop");
1790 }
1791
1792 #[allow(clippy::too_many_arguments)]
1795 fn entry_for_cost(
1796 model: &str,
1797 input: u64,
1798 output: u64,
1799 cr: u64,
1800 cw_total: u64,
1801 cw_1h: u64,
1802 web: u64,
1803 speed: Option<&str>,
1804 ) -> UsageEntry {
1805 UsageEntry {
1806 session_id: "s".into(),
1807 timestamp_ms: 0,
1808 model: model.into(),
1809 input_tokens: input,
1810 output_tokens: output,
1811 cache_read_tokens: cr,
1812 cache_write_tokens: cw_total,
1813 cache_write_1h_tokens: cw_1h,
1814 web_search_requests: web,
1815 speed: speed.map(|s| s.to_string()),
1816 tool_uses: Vec::new(),
1817 text_chars: 0,
1818 activity: Activity::General,
1819 entrypoint: None,
1820 }
1821 }
1822
1823 #[test]
1824 fn fable_pricing_all_token_classes() {
1825 let input = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, None);
1827 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1828 let output = entry_for_cost("claude-fable-5", 0, 1_000_000, 0, 0, 0, 0, None);
1829 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1830 let read = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1831 assert!((cost_for_entry(&read) - 1.0).abs() < 1e-6);
1832 let w5m = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 0, 0, None);
1833 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1834 let w1h = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1835 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1836 }
1837
1838 #[test]
1839 fn fable_has_no_fast_premium() {
1840 let fast = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1843 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1844 }
1845
1846 #[test]
1847 fn fable_context_window_is_natively_1m() {
1848 assert_eq!(context_window_for_model("claude-fable-5"), 1_000_000);
1850 assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
1851 assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
1852 }
1853
1854 #[test]
1855 fn cost_splits_5m_and_1h_cache_writes() {
1856 let all_1h = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1858 assert!((cost_for_entry(&all_1h) - 10.0).abs() < 1e-6);
1859 let all_5m = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 0, 0, None);
1860 assert!((cost_for_entry(&all_5m) - 6.25).abs() < 1e-6);
1861 let mixed = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 400_000, 0, None);
1863 assert!((cost_for_entry(&mixed) - 7.75).abs() < 1e-6);
1864 }
1865
1866 #[test]
1867 fn cost_adds_flat_web_search_fee() {
1868 let e = entry_for_cost("claude-sonnet-4-6", 0, 0, 0, 0, 0, 3, None);
1870 assert!((cost_for_entry(&e) - 0.03).abs() < 1e-9);
1871 }
1872
1873 #[test]
1874 fn fast_mode_multiplier_scales_token_cost_not_web_fee() {
1875 let std = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("standard"));
1877 assert!((cost_for_entry(&std) - 5.0).abs() < 1e-6);
1878 let fast = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1879 assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1880 let fast46 = entry_for_cost("claude-opus-4-6", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1882 assert!((cost_for_entry(&fast46) - 30.0).abs() < 1e-6);
1883 let fast_web = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 1, Some("fast"));
1885 assert!((cost_for_entry(&fast_web) - 10.01).abs() < 1e-6);
1886 }
1887
1888 #[test]
1889 fn fast_multiplier_only_for_known_opus() {
1890 assert_eq!(fast_multiplier_for_model("claude-opus-4-8"), 2.0);
1891 assert_eq!(fast_multiplier_for_model("claude-opus-4-7"), 6.0);
1892 assert_eq!(fast_multiplier_for_model("claude-opus-4-6"), 6.0);
1893 assert_eq!(fast_multiplier_for_model("claude-opus-5"), 2.0);
1894 assert_eq!(fast_multiplier_for_model("claude-opus-5[1m]"), 2.0);
1895 assert_eq!(fast_multiplier_for_model("claude-sonnet-4-6"), 1.0);
1896 assert_eq!(fast_multiplier_for_model("claude-fable-5-1"), 1.0);
1897 assert_eq!(fast_multiplier_for_model("claude-opus-6"), 1.0);
1899 }
1900
1901 #[test]
1902 fn fable_5_1_reads_cache_at_a_quarter_of_fable_5() {
1903 let read51 = entry_for_cost("claude-fable-5-1", 0, 0, 1_000_000, 0, 0, 0, None);
1906 assert!((cost_for_entry(&read51) - 0.25).abs() < 1e-6);
1907 let read5 = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1908 assert!((cost_for_entry(&read5) - 1.0).abs() < 1e-6);
1909
1910 let input = entry_for_cost("claude-fable-5-1", 1_000_000, 0, 0, 0, 0, 0, None);
1911 assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1912 let output = entry_for_cost("claude-fable-5-1", 0, 1_000_000, 0, 0, 0, 0, None);
1913 assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1914 let w5m = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 0, 0, None);
1915 assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1916 let w1h = entry_for_cost("claude-fable-5-1", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1917 assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1918 }
1919
1920 #[test]
1921 fn sonnet_5_is_cheaper_than_sonnet_4_6() {
1922 let in5 = entry_for_cost("claude-sonnet-5", 1_000_000, 0, 0, 0, 0, 0, None);
1925 assert!((cost_for_entry(&in5) - 2.0).abs() < 1e-6);
1926 let out5 = entry_for_cost("claude-sonnet-5", 0, 1_000_000, 0, 0, 0, 0, None);
1927 assert!((cost_for_entry(&out5) - 10.0).abs() < 1e-6);
1928 let read5 = entry_for_cost("claude-sonnet-5", 0, 0, 1_000_000, 0, 0, 0, None);
1929 assert!((cost_for_entry(&read5) - 0.2).abs() < 1e-6);
1930 let w5m = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 0, 0, None);
1931 assert!((cost_for_entry(&w5m) - 2.5).abs() < 1e-6);
1932 let w1h = entry_for_cost("claude-sonnet-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1933 assert!((cost_for_entry(&w1h) - 4.0).abs() < 1e-6);
1934
1935 let in46 = entry_for_cost("claude-sonnet-4-6", 1_000_000, 0, 0, 0, 0, 0, None);
1936 assert!((cost_for_entry(&in46) - 3.0).abs() < 1e-6);
1937 }
1938
1939 #[test]
1940 fn unknown_models_keep_the_conservative_sonnet_rate() {
1941 for id in ["", "<unknown>", "sonnet", "claude-3-5-sonnet-20241022"] {
1944 let p = pricing_for_model(id);
1945 assert_eq!(p.input, 3.0, "{id} should price at the Sonnet default");
1946 }
1947 assert_eq!(pricing_for_model("fable").cache_read, 1.0);
1950 }
1951
1952 #[test]
1953 fn family_version_reads_minor_and_ignores_date_snapshots() {
1954 assert_eq!(family_version("claude-fable-5-1", "fable"), Some((5, 1)));
1955 assert_eq!(family_version("claude-fable-5", "fable"), Some((5, 0)));
1956 assert_eq!(family_version("claude-sonnet-5", "sonnet"), Some((5, 0)));
1957 assert_eq!(family_version("claude-sonnet-4-6", "sonnet"), Some((4, 6)));
1958 assert_eq!(
1959 family_version("claude-haiku-4-5-20251001", "haiku"),
1960 Some((4, 5))
1961 );
1962 assert_eq!(
1964 family_version("claude-sonnet-5-20260701", "sonnet"),
1965 Some((5, 0))
1966 );
1967 assert_eq!(family_version("fable", "fable"), None);
1968 assert_eq!(family_version("claude-opus-4-8", "sonnet"), None);
1969 assert_eq!(family_version("claude-3-5-sonnet-20241022", "sonnet"), None);
1972 }
1973
1974 #[test]
1975 fn parse_extracts_cache_split_speed_and_web_search() {
1976 let line = r#"{"type":"assistant","timestamp":"2026-05-29T10:00:00.000Z","message":{"id":"msg_1","model":"claude-opus-4-8","usage":{"input_tokens":2,"output_tokens":673,"cache_read_input_tokens":0,"cache_creation_input_tokens":40702,"cache_creation":{"ephemeral_5m_input_tokens":700,"ephemeral_1h_input_tokens":40002},"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5},"speed":"fast"}}}"#;
1977 let (_tmp, path) = write_jsonl(&[line]);
1978 let entries = parse_jsonl_entries(&path).unwrap().entries;
1979 assert_eq!(entries.len(), 1);
1980 let e = &entries[0];
1981 assert_eq!(e.cache_write_tokens, 40702, "flat total preserved (== 5m+1h)");
1982 assert_eq!(e.cache_write_1h_tokens, 40002);
1983 assert_eq!(e.web_search_requests, 2);
1984 assert_eq!(e.speed.as_deref(), Some("fast"));
1985 }
1986
1987 #[test]
1988 fn parse_cache_write_takes_max_of_flat_and_split() {
1989 let line = r#"{"type":"assistant","timestamp":"2026-05-29T10:00:00.000Z","message":{"id":"msg_2","model":"claude-opus-4-8","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":500,"ephemeral_1h_input_tokens":900}}}}"#;
1991 let (_tmp, path) = write_jsonl(&[line]);
1992 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
1993 assert_eq!(e.cache_write_tokens, 1400, "max(100, 500+900)");
1994 assert_eq!(e.cache_write_1h_tokens, 900);
1995 }
1996
1997 #[test]
1998 fn parse_missing_nested_cache_falls_back_to_flat() {
1999 let line = assistant_line("msg_3", "claude-opus-4-8", "2026-05-29T10:00:00.000Z", 5, 10, 0, 30000);
2001 let (_tmp, path) = write_jsonl(&[&line]);
2002 let e = &parse_jsonl_entries(&path).unwrap().entries[0];
2003 assert_eq!(e.cache_write_tokens, 30000);
2004 assert_eq!(e.cache_write_1h_tokens, 0);
2005 assert_eq!(e.web_search_requests, 0);
2006 assert_eq!(e.speed, None);
2007 }
2008
2009 #[test]
2010 fn reduce_session_prices_1h_cache_premium() {
2011 let e = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
2013 let u = reduce_session(&[e], "", "sid").unwrap();
2014 assert!((u.estimated_cost_usd - 10.0).abs() < 1e-6);
2015 }
2016}