Skip to main content

claude_session_parser/
usage.rs

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
11/// Pricing per 1M tokens (USD) by model family.
12///
13/// Anthropic bills three prompt-cache tiers off the base input rate:
14/// 5-minute cache write = 1.25× input, 1-hour cache write = 2× input,
15/// cache read = 0.1× input. `cache_write` is the 5-minute rate (Claude Code's
16/// historical default); `cache_write_1h` is the 1-hour rate. Claude Code now
17/// emits `usage.cache_creation.ephemeral_1h_input_tokens` for the 1-hour
18/// portion, so the two must be priced separately or 1h writes undercount ~38%.
19pub struct ModelPricing {
20    pub input: f64,
21    pub output: f64,
22    pub cache_read: f64,
23    /// 5-minute cache write (1.25× input).
24    pub cache_write: f64,
25    /// 1-hour cache write (2× input).
26    pub cache_write_1h: f64,
27}
28
29/// Hardcoded as of 2026-06. Requires a code change + release when Anthropic updates pricing.
30pub fn pricing_for_model(model: &str) -> ModelPricing {
31    if model.contains("fable") {
32        // Fable 5: $10/$50 per MTok (2026-06). Cache rates derive from input
33        // at the standard multipliers (0.1× read, 1.25× 5m write, 2× 1h write).
34        ModelPricing {
35            input: 10.0,
36            output: 50.0,
37            cache_read: 1.0,
38            cache_write: 12.5,
39            cache_write_1h: 20.0,
40        }
41    } else if model.contains("opus") {
42        ModelPricing {
43            input: 5.0,
44            output: 25.0,
45            cache_read: 0.5,
46            cache_write: 6.25,
47            cache_write_1h: 10.0,
48        }
49    } else if model.contains("haiku") {
50        ModelPricing {
51            input: 1.0,
52            output: 5.0,
53            cache_read: 0.1,
54            cache_write: 1.25,
55            cache_write_1h: 2.0,
56        }
57    } else {
58        // Sonnet or unknown
59        ModelPricing {
60            input: 3.0,
61            output: 15.0,
62            cache_read: 0.3,
63            cache_write: 3.75,
64            cache_write_1h: 6.0,
65        }
66    }
67}
68
69/// Flat fee per Anthropic web-search server-tool request ($10 per 1,000 = $0.01).
70pub const WEB_SEARCH_COST_PER_REQUEST: f64 = 0.01;
71
72/// Fast-mode (research preview) cost multiplier, applied to the entire token
73/// cost line when `usage.speed == "fast"`. Fast mode is Opus-only and its
74/// premium varies by version (confirmed Anthropic pricing, 2026-05):
75/// - Opus 4.6 / 4.7 fast: 6× ($30/$150 vs $5/$25)
76/// - Opus 4.8 fast:       2× ($10/$50 vs $5/$25)
77///
78/// Caching multipliers stack on top of fast pricing, i.e. fast scales the whole
79/// per-token line (input, output, and the cache rates that derive from input).
80/// Unknown / unsupported models return 1.0 (no premium) so we never overcount
81/// on a guess — newer Opus releases default here until their multiplier is added.
82/// Fable 5 has no fast-mode variant (fast is Opus 4.6-4.8 only) → 1.0.
83pub fn fast_multiplier_for_model(model: &str) -> f64 {
84    if model.contains("opus-4-8") {
85        2.0
86    } else if model.contains("opus-4-7") || model.contains("opus-4-6") {
87        6.0
88    } else {
89        1.0
90    }
91}
92
93/// Single source of truth for per-turn cost (USD), used by both the live status
94/// bar (`reduce_session`) and the Usage page (`commands::usage`).
95///
96/// Splits cache-write tokens into 5-minute and 1-hour tiers, applies the
97/// fast-mode multiplier to the token line when the turn ran in fast mode, and
98/// adds the flat web-search fee (which is not a token cost and so is unaffected
99/// by the fast multiplier).
100pub fn cost_for_entry(entry: &UsageEntry) -> f64 {
101    let p = pricing_for_model(&entry.model);
102    let cw_1h = entry.cache_write_1h_tokens.min(entry.cache_write_tokens);
103    let cw_5m = entry.cache_write_tokens - cw_1h;
104    let token_cost = entry.input_tokens as f64 * p.input
105        + entry.output_tokens as f64 * p.output
106        + entry.cache_read_tokens as f64 * p.cache_read
107        + cw_5m as f64 * p.cache_write
108        + cw_1h as f64 * p.cache_write_1h;
109    let fast = if entry.speed.as_deref() == Some("fast") {
110        fast_multiplier_for_model(&entry.model)
111    } else {
112        1.0
113    };
114    (token_cost * fast) / 1_000_000.0
115        + entry.web_search_requests as f64 * WEB_SEARCH_COST_PER_REQUEST
116}
117
118pub fn context_window_for_model(model: &str) -> u64 {
119    // Fable 5 is natively 1M — there is no `[1m]` suffix variant for it.
120    if model.contains("[1m]") || model.contains("fable") {
121        1_000_000
122    } else {
123        200_000
124    }
125}
126
127/// Flat row representing a single completed assistant turn.
128///
129/// One entry per unique `message.id`. Claude Code writes multiple JSONL
130/// entries per assistant message as the stream progresses (typically 2-3),
131/// all sharing the same `id` and final usage numbers — dedup keeps only the
132/// first occurrence.
133///
134/// Token field semantics (inherited from Anthropic's API):
135/// - `input_tokens`: *fresh* tokens in this turn's prompt that were NOT served
136///   from cache. Small when conversation is reused, large on cache misses.
137/// - `cache_read_tokens`: tokens served from the prompt cache (cheap).
138/// - `cache_write_tokens`: tokens written into the prompt cache (~25% premium).
139/// - `output_tokens`: tokens generated by the model.
140///
141/// "Total context sent" for a turn = input + cache_read + cache_write.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct UsageEntry {
145    pub session_id: String,
146    /// Unix epoch ms from the JSONL `timestamp` field. 0 if unparseable.
147    pub timestamp_ms: u64,
148    pub model: String,
149    pub input_tokens: u64,
150    pub output_tokens: u64,
151    pub cache_read_tokens: u64,
152    /// Total cache-write (creation) tokens for this turn: `max(legacy flat
153    /// `cache_creation_input_tokens`, ephemeral_5m + ephemeral_1h)`. The 1-hour
154    /// subset is broken out separately in `cache_write_1h_tokens` for pricing;
155    /// this total still drives cache-hit % and token displays.
156    pub cache_write_tokens: u64,
157    /// The 1-hour-TTL portion of `cache_write_tokens` (from
158    /// `usage.cache_creation.ephemeral_1h_input_tokens`). Billed at 2× input
159    /// vs the 5-minute tier's 1.25×. The 5-minute portion is the remainder.
160    #[serde(default)]
161    pub cache_write_1h_tokens: u64,
162    /// Anthropic web-search server-tool requests this turn
163    /// (`usage.server_tool_use.web_search_requests`). Billed flat at $0.01 each.
164    #[serde(default)]
165    pub web_search_requests: u64,
166    /// `usage.speed`: `"standard"` or `"fast"`. `Some("fast")` applies the
167    /// fast-mode cost multiplier (see `fast_multiplier_for_model`). `None` on
168    /// rows that predate the field.
169    #[serde(default)]
170    pub speed: Option<String>,
171    /// Names of tools this turn invoked (from `tool_use` content blocks).
172    /// Empty for pure-text turns. Duplicates preserved when the model called
173    /// the same tool multiple times in one turn.
174    pub tool_uses: Vec<String>,
175    /// Sum of `text` content block character counts for this turn. Zero means
176    /// a silent tool-only turn; non-zero means the model said something.
177    pub text_chars: u32,
178    /// Classified activity category (Coding, Debugging, Exploration, …).
179    /// See the [`crate::activity`] module for the decision tree.
180    pub activity: Activity,
181    /// JSONL `entrypoint` field captured from the first row of each message id.
182    /// `"cli"` = interactive Claude Code session. `"sdk-cli"` = headless
183    /// `claude -p` spawn (any Agent SDK consumer lands here). `None` for legacy
184    /// rows that predate the field. Useful for distinguishing interactive vs.
185    /// headless usage; also load-bearing once Anthropic splits Agent SDK
186    /// billing from interactive plans (announced June 2026).
187    pub entrypoint: Option<String>,
188}
189
190/// A single hook execution event from a `type: "attachment"` JSONL record
191/// where `attachment.type == "hook_success"`.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct HookExecution {
195    pub session_id: String,
196    pub timestamp_ms: u64,
197    /// Lifecycle phase: `PreToolUse`, `PostToolUse`, `SessionStart`, `Stop`, etc.
198    pub hook_event: String,
199    /// Full hook label including matcher: `PreToolUse:Bash`, `Stop`, etc.
200    pub hook_name: String,
201    /// The shell command that was executed.
202    pub command: String,
203    pub exit_code: i32,
204    pub duration_ms: u64,
205    /// The tool-use ID that triggered this hook (groups multiple hooks per tool call).
206    pub tool_use_id: Option<String>,
207}
208
209/// Combined result from parsing a single JSONL file.
210#[derive(Default)]
211pub struct ParsedJsonl {
212    pub entries: Vec<UsageEntry>,
213    pub hooks: Vec<HookExecution>,
214}
215
216/// Usage summary for a single session, returned to the frontend status bar.
217#[derive(Debug, Clone, Serialize, Default)]
218#[serde(rename_all = "camelCase")]
219pub struct SessionUsage {
220    pub model: String,
221    pub input_tokens: u64,
222    pub output_tokens: u64,
223    pub cache_read_tokens: u64,
224    pub cache_write_tokens: u64,
225    pub total_tokens: u64,
226    pub estimated_cost_usd: f64,
227    pub context_window_size: u64,
228    pub context_window_used_pct: f64,
229    /// Last message's total input context (current context window usage).
230    pub current_context_tokens: u64,
231    /// Cumulative context sent (input + cache_read + cache_write).
232    pub total_context_sent: u64,
233    /// cache_read / (input + cache_read), in percent. 0 when no cache activity.
234    pub cache_hit_pct: f64,
235    pub message_count: u64,
236    pub session_id: String,
237}
238
239/// Minimal JSONL entry structure.
240#[derive(Deserialize)]
241#[serde(rename_all = "camelCase")]
242struct JsonlRaw {
243    #[serde(rename = "type")]
244    entry_type: Option<String>,
245    timestamp: Option<String>,
246    message: Option<MessageData>,
247    attachment: Option<AttachmentData>,
248    // Fields for `type: "system"` hook summary records (v2.1.119+).
249    subtype: Option<String>,
250    #[allow(dead_code)]
251    hook_count: Option<usize>,
252    hook_infos: Option<Vec<HookInfoEntry>>,
253    hook_errors: Option<Vec<serde_json::Value>>,
254    prevented_continuation: Option<bool>,
255    #[serde(rename = "toolUseID")]
256    tool_use_id: Option<String>,
257    /// Origin of the session: `"cli"` (interactive Claude Code) or `"sdk-cli"`
258    /// (headless `claude -p` / Agent SDK). Present on every assistant + user row.
259    entrypoint: Option<String>,
260}
261
262#[derive(Deserialize)]
263struct MessageData {
264    id: Option<String>,
265    model: Option<String>,
266    usage: Option<UsageData>,
267    /// Raw content — assistant messages use `Vec<ContentBlock>`, user messages
268    /// use either a string or an array of blocks (including tool_results).
269    /// Kept as a raw Value so we can handle both shapes without maintaining
270    /// two parallel structs.
271    #[serde(default)]
272    content: Option<serde_json::Value>,
273}
274
275#[derive(Deserialize, Default)]
276struct UsageData {
277    input_tokens: Option<u64>,
278    output_tokens: Option<u64>,
279    cache_read_input_tokens: Option<u64>,
280    /// Legacy flat total of cache-creation tokens. Still emitted alongside the
281    /// nested `cache_creation` split; we take the max of the two for safety.
282    cache_creation_input_tokens: Option<u64>,
283    /// 5-minute / 1-hour cache-write split (Claude Code ≥ the 1h-cache rollout).
284    cache_creation: Option<CacheCreation>,
285    /// Server-tool request counts (web search / web fetch).
286    server_tool_use: Option<ServerToolUse>,
287    /// `"standard"` | `"fast"`. Drives the fast-mode cost multiplier.
288    speed: Option<String>,
289}
290
291/// Nested `usage.cache_creation` object splitting cache writes by TTL tier.
292#[derive(Deserialize, Default)]
293struct CacheCreation {
294    ephemeral_5m_input_tokens: Option<u64>,
295    ephemeral_1h_input_tokens: Option<u64>,
296}
297
298/// Nested `usage.server_tool_use` object. Only `web_search_requests` is billable
299/// ($0.01/req); `web_fetch_requests` is free and intentionally not priced.
300#[derive(Deserialize, Default)]
301struct ServerToolUse {
302    web_search_requests: Option<u64>,
303}
304
305/// Single hook execution in a `hookInfos` array (v2.1.119+ system records).
306#[derive(Deserialize)]
307#[serde(rename_all = "camelCase")]
308struct HookInfoEntry {
309    command: Option<String>,
310    duration_ms: Option<u64>,
311}
312
313/// Minimal deserialization of the `attachment` field on `type: "attachment"` rows.
314#[derive(Deserialize)]
315#[serde(rename_all = "camelCase")]
316struct AttachmentData {
317    #[serde(rename = "type")]
318    attachment_type: Option<String>,
319    hook_name: Option<String>,
320    hook_event: Option<String>,
321    command: Option<String>,
322    exit_code: Option<i32>,
323    duration_ms: Option<u64>,
324    #[serde(rename = "toolUseID")]
325    tool_use_id: Option<String>,
326}
327
328/// Claude Code active session file in `{config_dir}/sessions/{pid}.json`.
329#[derive(Deserialize)]
330#[serde(rename_all = "camelCase")]
331struct ActiveSession {
332    session_id: String,
333    cwd: String,
334    started_at: u64,
335}
336
337/// Hard cap to avoid pathological parsing of a corrupted file.
338///
339/// Parsing is streaming (`BufReader::lines()`) so memory is O(1) per line —
340/// the cap only exists to keep a runaway file from blocking the UI. Real
341/// agent sessions (Task-tool sub-agents) frequently exceed 30MB, so the cap
342/// needs generous headroom.
343use super::MAX_JSONL_BYTES;
344
345/// Encode a project path the same way Claude Code does.
346///
347/// Windows: `S:\Loom` -> `S--Loom`, `C:\Users\Seven\foo` -> `C--Users-Seven-foo`
348/// macOS/Linux: `/Users/seven/Loom` -> `-Users-seven-Loom`, `/home/alice/proj` -> `-home-alice-proj`
349///
350/// The transform collapses any leading path separator(s) into a single leading
351/// dash, which is how Claude Code writes `~/.claude/projects/` directory names
352/// on Unix. Verified against ccusage's decoder (see GitHub issue
353/// anthropics/claude-code#19972 for the path-encoding discussion).
354pub fn encode_project_path(project_path: &str) -> String {
355    let normalized = project_path.replace('/', "\\");
356    let mut result = String::new();
357    let mut chars = normalized.chars().peekable();
358
359    while let Some(ch) = chars.next() {
360        if ch == ':' {
361            if chars.peek() == Some(&'\\') {
362                chars.next();
363                result.push_str("--");
364            } else {
365                result.push('-');
366            }
367        } else if ch == '\\' {
368            result.push('-');
369        } else {
370            result.push(ch);
371        }
372    }
373
374    while result.ends_with('-') {
375        result.pop();
376    }
377
378    result
379}
380
381/// Extracted signal from an assistant message's `content` array.
382struct AssistantContent {
383    tool_uses: Vec<String>,
384    bash_cmds: Vec<String>,
385    text_chars: u32,
386}
387
388/// Walk an assistant message's content blocks to pull out tool names, any
389/// bash-style commands (used by the classifier for Testing/GitOps/Build
390/// detection), and the total character count of `text` blocks.
391fn extract_assistant_content(content: Option<&serde_json::Value>) -> AssistantContent {
392    let mut out = AssistantContent {
393        tool_uses: Vec::new(),
394        bash_cmds: Vec::new(),
395        text_chars: 0,
396    };
397    let arr = match content.and_then(|v| v.as_array()) {
398        Some(a) => a,
399        None => return out,
400    };
401
402    for block in arr {
403        let block_type = match block.get("type").and_then(|v| v.as_str()) {
404            Some(t) => t,
405            None => continue,
406        };
407        match block_type {
408            "tool_use" => {
409                if let Some(name) = block.get("name").and_then(|v| v.as_str()) {
410                    // If this is a Bash-family tool, grab the command string so
411                    // the classifier can distinguish Testing / GitOps / Build.
412                    if matches!(name, "Bash" | "BashTool" | "PowerShellTool") {
413                        if let Some(cmd) = block
414                            .get("input")
415                            .and_then(|i| i.get("command"))
416                            .and_then(|c| c.as_str())
417                        {
418                            out.bash_cmds.push(cmd.to_string());
419                        }
420                    }
421                    out.tool_uses.push(name.to_string());
422                }
423            }
424            "text" => {
425                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
426                    // Saturating because a single turn's text can exceed u32
427                    // only in pathological cases (would be >4 GB of text).
428                    out.text_chars = out
429                        .text_chars
430                        .saturating_add(text.chars().count() as u32);
431                }
432            }
433            _ => {}
434        }
435    }
436
437    out
438}
439
440/// Extract the human-typed portion of a user message's content, if any.
441///
442/// User rows in Claude Code JSONL carry two distinct shapes:
443/// - A plain string (`"content": "hello"`) — always a real user turn.
444/// - An array of content blocks, which may be:
445///   - `[{"type":"text","text":"..."}]` → real user turn
446///   - `[{"type":"tool_result",...}]` → injected tool output, NOT a user turn
447///   - Mixed → treated as a real user turn
448///
449/// Sanitize the per-hook `command` for use inside a synthesized `hook_name`
450/// bucket key. Takes the first line, collapses internal whitespace, and caps
451/// at 64 chars (with `…` suffix). The full command is preserved separately on
452/// the `HookExecution.command` field — this only affects the display label /
453/// aggregation key used by the Usage page and Refinery hook stats.
454///
455/// Without this, `/goal`-style hooks whose "command" is a multi-KB goal
456/// statement explode the hook-table layout AND defeat aggregation (one bucket
457/// per invocation).
458fn sanitize_hook_command_label(cmd: &str) -> String {
459    const MAX_LEN: usize = 64;
460    let first_line = cmd.lines().next().unwrap_or("").trim();
461    let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
462    if collapsed.chars().count() <= MAX_LEN {
463        return collapsed;
464    }
465    let truncated: String = collapsed.chars().take(MAX_LEN).collect();
466    format!("{truncated}…")
467}
468
469/// Convert `snake_case` to `PascalCase`: `"stop"` → `"Stop"`, `"pre_tool_use"` → `"PreToolUse"`.
470fn snake_to_pascal(s: &str) -> String {
471    s.split('_')
472        .filter(|w| !w.is_empty())
473        .map(|w| {
474            let mut c = w.chars();
475            match c.next() {
476                Some(first) => {
477                    let mut out = first.to_uppercase().to_string();
478                    out.extend(c);
479                    out
480                }
481                None => String::new(),
482            }
483        })
484        .collect()
485}
486
487/// Returns `None` for tool-result-only rows so the rolling `last_user_text`
488/// stays pinned to the most recent actual user message.
489fn extract_user_text(content: &serde_json::Value) -> Option<String> {
490    match content {
491        serde_json::Value::String(s) => {
492            if s.is_empty() {
493                None
494            } else {
495                Some(s.clone())
496            }
497        }
498        serde_json::Value::Array(arr) => {
499            let mut parts: Vec<&str> = Vec::new();
500            for item in arr {
501                let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
502                if t == "text" {
503                    if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
504                        if !text.is_empty() {
505                            parts.push(text);
506                        }
507                    }
508                }
509            }
510            if parts.is_empty() {
511                None
512            } else {
513                Some(parts.join(" "))
514            }
515        }
516        _ => None,
517    }
518}
519
520/// Per-message-id accumulator for content blocks that arrive split across
521/// multiple JSONL rows. See `parse_jsonl_entries` for the why.
522struct EntryAccumulator {
523    out_idx: usize,
524    bash_cmds: Vec<String>,
525    user_text_at_first: Option<String>,
526}
527
528/// Parse a single JSONL file into one `UsageEntry` per unique `message.id`.
529///
530/// Pure function: depends only on the file contents. Non-assistant entries,
531/// malformed lines, and missing IDs are skipped silently.
532///
533/// **Dedup is split by field semantics**, because Claude Code emits one JSONL
534/// row per assistant content block (thinking / text / each tool_use) all
535/// sharing the same `message.id`:
536///
537/// - Billing fields (token counts, model, timestamp, session_id) come from the
538///   **first** row seen for each id — they repeat identically across rows.
539/// - Content fields (`tool_uses`, `text_chars`, and the bash commands fed to
540///   the activity classifier) are **unioned across every row** with that id,
541///   then `activity` is classified once at the end using the merged content.
542///
543/// Folding only by `message.id` (as this parser did before 2026-05-13) drops
544/// every tool_use after the first row, which made the Toolkit and Usage tool
545/// counters undercount by an order of magnitude. The chat parser already
546/// uses `(message_id + content)` for the same reason — see CLAUDE.md.
547///
548/// Runs a rolling `last_user_text` cursor during the stream: each genuine
549/// `type: "user"` row updates it; the snapshot taken at the **first** row of
550/// each assistant message is used for activity classification.
551pub fn parse_jsonl_entries(path: &Path) -> std::io::Result<ParsedJsonl> {
552    let meta = fs::metadata(path)?;
553    if meta.len() > MAX_JSONL_BYTES {
554        tracing::warn!(
555            path = %path.display(),
556            size = meta.len(),
557            "skipping JSONL larger than {}MB",
558            MAX_JSONL_BYTES / 1024 / 1024
559        );
560        return Ok(ParsedJsonl::default());
561    }
562
563    let file = fs::File::open(path)?;
564    let reader = BufReader::new(file);
565
566    let session_id = path
567        .file_stem()
568        .and_then(|s| s.to_str())
569        .unwrap_or("")
570        .to_string();
571
572    let mut accums: HashMap<String, EntryAccumulator> = HashMap::new();
573    let mut out: Vec<UsageEntry> = Vec::new();
574    let mut hooks: Vec<HookExecution> = Vec::new();
575    let mut last_user_text: Option<String> = None;
576
577    for line in reader.lines() {
578        let line = match line {
579            Ok(l) => l,
580            Err(_) => continue,
581        };
582        let line = line.trim();
583        if line.is_empty() {
584            continue;
585        }
586
587        let raw: JsonlRaw = match serde_json::from_str(line) {
588            Ok(r) => r,
589            Err(_) => continue,
590        };
591
592        let entry_type = raw.entry_type.as_deref();
593
594        // Update the rolling user-message cursor on genuine user turns.
595        // Tool-result-only rows (also type="user") leave it untouched.
596        if entry_type == Some("user") {
597            if let Some(msg) = raw.message.as_ref() {
598                if let Some(content) = msg.content.as_ref() {
599                    if let Some(text) = extract_user_text(content) {
600                        last_user_text = Some(normalize_user_text(&text));
601                    }
602                }
603            }
604            continue;
605        }
606
607        // Extract hook execution events from attachment records (v2.1.101 format).
608        if entry_type == Some("attachment") {
609            if let Some(att) = raw.attachment {
610                if att.attachment_type.as_deref() == Some("hook_success") {
611                    let timestamp_ms = raw
612                        .timestamp
613                        .as_deref()
614                        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
615                        .map(|dt| dt.timestamp_millis().max(0) as u64)
616                        .unwrap_or(0);
617                    hooks.push(HookExecution {
618                        session_id: session_id.clone(),
619                        timestamp_ms,
620                        hook_event: att.hook_event.unwrap_or_default(),
621                        hook_name: att.hook_name.unwrap_or_default(),
622                        command: att.command.unwrap_or_default(),
623                        exit_code: att.exit_code.unwrap_or(0),
624                        duration_ms: att.duration_ms.unwrap_or(0),
625                        tool_use_id: att.tool_use_id,
626                    });
627                }
628            }
629            continue;
630        }
631
632        // Extract hook summaries from system records (v2.1.119+ format).
633        // Subtype pattern: "<event>_hook_summary" → hook_event = "<Event>".
634        if entry_type == Some("system") {
635            if let Some(subtype) = raw.subtype.as_deref() {
636                if let Some(event_snake) = subtype.strip_suffix("_hook_summary") {
637                    let hook_event = snake_to_pascal(event_snake);
638                    let timestamp_ms = raw
639                        .timestamp
640                        .as_deref()
641                        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
642                        .map(|dt| dt.timestamp_millis().max(0) as u64)
643                        .unwrap_or(0);
644                    let had_errors = raw
645                        .hook_errors
646                        .as_ref()
647                        .is_some_and(|e| !e.is_empty());
648                    let prevented = raw.prevented_continuation.unwrap_or(false);
649                    if let Some(infos) = raw.hook_infos {
650                        for info in infos {
651                            let cmd = info.command.unwrap_or_default();
652                            let hook_name =
653                                format!("{}:{}", hook_event, sanitize_hook_command_label(&cmd));
654                            hooks.push(HookExecution {
655                                session_id: session_id.clone(),
656                                timestamp_ms,
657                                hook_event: hook_event.clone(),
658                                hook_name,
659                                command: cmd,
660                                exit_code: if had_errors || prevented { 1 } else { 0 },
661                                duration_ms: info.duration_ms.unwrap_or(0),
662                                tool_use_id: raw.tool_use_id.clone(),
663                            });
664                        }
665                    }
666                }
667            }
668            continue;
669        }
670
671        if entry_type != Some("assistant") {
672            continue;
673        }
674
675        let msg = match raw.message {
676            Some(m) => m,
677            None => continue,
678        };
679        let mid = match msg.id {
680            Some(id) => id,
681            None => continue,
682        };
683
684        let mut content_parsed = extract_assistant_content(msg.content.as_ref());
685
686        // Subsequent row for an already-seen message: union its content into
687        // the existing entry. Billing/model/timestamp on the entry stay as-is.
688        if let Some(acc) = accums.get_mut(&mid) {
689            let entry = &mut out[acc.out_idx];
690            entry
691                .tool_uses
692                .append(&mut content_parsed.tool_uses);
693            entry.text_chars = entry
694                .text_chars
695                .saturating_add(content_parsed.text_chars);
696            acc.bash_cmds.append(&mut content_parsed.bash_cmds);
697            continue;
698        }
699
700        // Skip synthetic entries. Claude Code / the Agent SDK writes
701        // `model: "<synthetic>"` rows for internal routing (skill listings,
702        // queue ops, tool dispatch, etc.) — these never hit the LLM and have
703        // all-zero token counts. Counting them would inflate turn counts
704        // without representing real model activity.
705        let model = msg.model.unwrap_or_default();
706        if model == "<synthetic>" {
707            continue;
708        }
709
710        let usage = msg.usage.unwrap_or_default();
711        let timestamp_ms = raw
712            .timestamp
713            .as_deref()
714            .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
715            .map(|dt| dt.timestamp_millis().max(0) as u64)
716            .unwrap_or(0);
717
718        // Cache-write tokens: prefer the larger of the legacy flat total and the
719        // 5m+1h split (guards partial/missing fields across Claude Code
720        // versions). Break out the 1-hour subset for tiered pricing.
721        let cw_flat = usage.cache_creation_input_tokens.unwrap_or(0);
722        let (eph_5m, eph_1h) = usage
723            .cache_creation
724            .as_ref()
725            .map(|c| {
726                (
727                    c.ephemeral_5m_input_tokens.unwrap_or(0),
728                    c.ephemeral_1h_input_tokens.unwrap_or(0),
729                )
730            })
731            .unwrap_or((0, 0));
732        let cache_write_tokens = cw_flat.max(eph_5m + eph_1h);
733        let cache_write_1h_tokens = eph_1h.min(cache_write_tokens);
734        let web_search_requests = usage
735            .server_tool_use
736            .as_ref()
737            .map(|s| s.web_search_requests.unwrap_or(0))
738            .unwrap_or(0);
739        let speed = usage.speed.clone();
740
741        // First row for this message: push placeholder entry with the content
742        // we've seen so far. Activity is classified at the end of the file
743        // against the merged content from all rows sharing this `message.id`.
744        accums.insert(
745            mid.clone(),
746            EntryAccumulator {
747                out_idx: out.len(),
748                bash_cmds: std::mem::take(&mut content_parsed.bash_cmds),
749                user_text_at_first: last_user_text.clone(),
750            },
751        );
752        out.push(UsageEntry {
753            session_id: session_id.clone(),
754            timestamp_ms,
755            model,
756            input_tokens: usage.input_tokens.unwrap_or(0),
757            output_tokens: usage.output_tokens.unwrap_or(0),
758            cache_read_tokens: usage.cache_read_input_tokens.unwrap_or(0),
759            cache_write_tokens,
760            cache_write_1h_tokens,
761            web_search_requests,
762            speed,
763            tool_uses: content_parsed.tool_uses,
764            text_chars: content_parsed.text_chars,
765            activity: Activity::General,
766            entrypoint: raw.entrypoint.clone(),
767        });
768    }
769
770    // Classify each accumulated entry using the merged content from all rows
771    // that shared its `message.id`. Order doesn't matter — we mutate in place.
772    for acc in accums.values() {
773        let entry = &mut out[acc.out_idx];
774        let tool_refs: Vec<&str> = entry.tool_uses.iter().map(|s| s.as_str()).collect();
775        let bash_refs: Vec<&str> = acc.bash_cmds.iter().map(|s| s.as_str()).collect();
776        entry.activity = classify(
777            &tool_refs,
778            &bash_refs,
779            acc.user_text_at_first.as_deref(),
780            entry.text_chars,
781        );
782    }
783
784    Ok(ParsedJsonl {
785        entries: out,
786        hooks,
787    })
788}
789
790/// Find the JSONL file for the current session by looking up Claude Code's
791/// active sessions directory, matching on project path.
792/// Falls back to finding the newest JSONL created after `spawned_at`.
793pub fn find_current_session_jsonl(
794    config_dir: &str,
795    project_path: &str,
796    spawned_at: SystemTime,
797) -> Option<PathBuf> {
798    let config = Path::new(config_dir);
799    let encoded = encode_project_path(project_path);
800    let projects_dir = config.join("projects").join(&encoded);
801
802    if !projects_dir.exists() {
803        return None;
804    }
805
806    // Strategy 1: Look up Claude Code's sessions/ directory for an active
807    // session matching our project path. This gives us the exact session ID.
808    let sessions_dir = config.join("sessions");
809    if sessions_dir.exists() {
810        if let Some(session_id) = find_active_session_id(&sessions_dir, project_path, spawned_at) {
811            let jsonl_path = projects_dir.join(format!("{}.jsonl", session_id));
812            if jsonl_path.exists() {
813                return Some(jsonl_path);
814            }
815        }
816    }
817
818    // Strategy 2: Fall back to finding the earliest-created JSONL after spawn
819    // time. "Earliest" is critical: with two concurrent sessions for the same
820    // project, the older session must claim the first JSONL created after its
821    // spawn, not the latest one (which belongs to the newer session).
822    let mut candidates: Vec<(PathBuf, SystemTime)> = Vec::new();
823
824    if let Ok(entries) = fs::read_dir(&projects_dir) {
825        for entry in entries.flatten() {
826            let path = entry.path();
827            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
828                continue;
829            }
830            if let Ok(meta) = path.metadata() {
831                let created = meta.created().unwrap_or(SystemTime::UNIX_EPOCH);
832                if created >= spawned_at {
833                    candidates.push((path, created));
834                }
835            }
836        }
837    }
838
839    // Pick the earliest-created file after our spawn time
840    candidates.sort_by_key(|a| a.1);
841    candidates.into_iter().next().map(|(p, _)| p)
842}
843
844/// Search Claude Code's sessions/ directory for an active session matching
845/// the project path and started after our spawn time.
846fn find_active_session_id(
847    sessions_dir: &Path,
848    project_path: &str,
849    spawned_at: SystemTime,
850) -> Option<String> {
851    let spawned_ms = spawned_at
852        .duration_since(SystemTime::UNIX_EPOCH)
853        .ok()?
854        .as_millis() as u64;
855
856    // Allow 10s grace period (Claude Code may start slightly after PTY spawn)
857    let threshold_ms = spawned_ms.saturating_sub(10_000);
858
859    let normalized_project = normalize_path(project_path);
860
861    let mut best: Option<(String, u64)> = None;
862
863    if let Ok(entries) = fs::read_dir(sessions_dir) {
864        for entry in entries.flatten() {
865            let path = entry.path();
866            if path.extension().and_then(|e| e.to_str()) != Some("json") {
867                continue;
868            }
869            if let Ok(content) = fs::read_to_string(&path) {
870                if let Ok(session) = serde_json::from_str::<ActiveSession>(&content) {
871                    let session_cwd = normalize_path(&session.cwd);
872                    if session_cwd == normalized_project && session.started_at >= threshold_ms {
873                        // Pick the earliest session started after our spawn
874                        // threshold. With multiple concurrent sessions for the
875                        // same project, each has a different spawned_at: the
876                        // older session's threshold is lower, so it must claim
877                        // the first matching Claude session, not the latest one.
878                        if best.as_ref().is_none_or(|(_, t)| session.started_at < *t) {
879                            best = Some((session.session_id, session.started_at));
880                        }
881                    }
882                }
883            }
884        }
885    }
886
887    best.map(|(id, _)| id)
888}
889
890/// Normalize a path for comparison: lowercase, forward slashes, no trailing slash.
891fn normalize_path(p: &str) -> String {
892    p.replace('\\', "/")
893        .trim_end_matches('/')
894        .to_lowercase()
895}
896
897/// Reduce a list of `UsageEntry` for a single session into a `SessionUsage`.
898///
899/// Preserves existing semantics:
900/// - Primary model = the model used by the most messages (mode).
901/// - Context window % uses the LAST entry's total input (API resends the full
902///   conversation each turn; the last turn's input is the current usage).
903/// - `launched_model` (from launch options) is used for context window sizing
904///   because JSONL strips the `[1m]` suffix; fall back to mode when empty.
905pub fn reduce_session(
906    entries: &[UsageEntry],
907    launched_model: &str,
908    session_id_hint: &str,
909) -> Option<SessionUsage> {
910    if entries.is_empty() {
911        return None;
912    }
913
914    let mut input_tokens = 0u64;
915    let mut output_tokens = 0u64;
916    let mut cache_read = 0u64;
917    let mut cache_write = 0u64;
918    let mut model_counts: HashMap<&str, u64> = HashMap::new();
919
920    for e in entries {
921        input_tokens += e.input_tokens;
922        output_tokens += e.output_tokens;
923        cache_read += e.cache_read_tokens;
924        cache_write += e.cache_write_tokens;
925        if !e.model.is_empty() {
926            *model_counts.entry(e.model.as_str()).or_insert(0) += 1;
927        }
928    }
929
930    let primary_model = model_counts
931        .into_iter()
932        .max_by_key(|(_, c)| *c)
933        .map(|(m, _)| m.to_string())
934        .unwrap_or_else(|| {
935            entries
936                .last()
937                .map(|e| e.model.clone())
938                .unwrap_or_default()
939        });
940
941    // Sum per-entry cost so mixed-model sessions, the 1-hour cache premium, the
942    // fast-mode multiplier, and web-search fees are all priced correctly — the
943    // single source of truth is `cost_for_entry`.
944    let estimated_cost: f64 = entries.iter().map(cost_for_entry).sum();
945
946    let context_model = if launched_model.is_empty() {
947        &primary_model
948    } else {
949        launched_model
950    };
951    let context_window = context_window_for_model(context_model);
952
953    let last = entries.last().unwrap();
954    let last_context_used =
955        last.input_tokens + last.cache_read_tokens + last.cache_write_tokens;
956    let context_pct = if context_window > 0 {
957        (last_context_used as f64 / context_window as f64 * 100.0).min(100.0)
958    } else {
959        0.0
960    };
961
962    let total = input_tokens + output_tokens + cache_read + cache_write;
963    let total_context_sent = input_tokens + cache_read + cache_write;
964
965    // Cache hit % reflects "what fraction of billable input-side tokens came
966    // from cache". Denominator includes cache_write because writes are fresh
967    // input being laid down for later reuse (billed at ~125% of input rate) —
968    // they are NOT a hit. Only cache_read counts as reusing prior work.
969    let cache_denom = input_tokens + cache_read + cache_write;
970    let cache_hit_pct = if cache_denom > 0 {
971        (cache_read as f64 / cache_denom as f64) * 100.0
972    } else {
973        0.0
974    };
975
976    let session_id = entries
977        .first()
978        .map(|e| e.session_id.clone())
979        .filter(|s| !s.is_empty())
980        .unwrap_or_else(|| session_id_hint.to_string());
981
982    Some(SessionUsage {
983        model: primary_model,
984        input_tokens,
985        output_tokens,
986        cache_read_tokens: cache_read,
987        cache_write_tokens: cache_write,
988        total_tokens: total,
989        estimated_cost_usd: estimated_cost,
990        context_window_size: context_window,
991        context_window_used_pct: context_pct,
992        current_context_tokens: last_context_used,
993        total_context_sent,
994        cache_hit_pct,
995        message_count: entries.len() as u64,
996        session_id,
997    })
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003    use std::io::Write;
1004    use tempfile::TempDir;
1005
1006    #[test]
1007    fn test_encode_project_path_drive_root() {
1008        assert_eq!(encode_project_path("S:\\Loom"), "S--Loom");
1009    }
1010
1011    #[test]
1012    fn test_encode_project_path_deep() {
1013        assert_eq!(
1014            encode_project_path("C:\\Users\\Seven\\Desktop\\SF\\project"),
1015            "C--Users-Seven-Desktop-SF-project"
1016        );
1017    }
1018
1019    #[test]
1020    fn test_encode_project_path_forward_slashes() {
1021        assert_eq!(encode_project_path("S:/Loom"), "S--Loom");
1022    }
1023
1024    #[test]
1025    fn test_encode_project_path_trailing_slash() {
1026        assert_eq!(encode_project_path("S:\\Loom\\"), "S--Loom");
1027    }
1028
1029    #[test]
1030    fn test_encode_project_path_macos() {
1031        assert_eq!(
1032            encode_project_path("/Users/seven/Loom"),
1033            "-Users-seven-Loom"
1034        );
1035    }
1036
1037    #[test]
1038    fn test_encode_project_path_linux() {
1039        assert_eq!(
1040            encode_project_path("/home/alice/projects/foo"),
1041            "-home-alice-projects-foo"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_encode_project_path_unix_trailing_slash() {
1047        assert_eq!(encode_project_path("/Users/seven/Loom/"), "-Users-seven-Loom");
1048    }
1049
1050    #[test]
1051    fn test_normalize_path() {
1052        assert_eq!(normalize_path("S:\\Loom\\"), "s:/loom");
1053        assert_eq!(normalize_path("S:/Loom"), "s:/loom");
1054        assert_eq!(normalize_path("C:\\Users\\Seven\\"), "c:/users/seven");
1055    }
1056
1057    fn write_jsonl(lines: &[&str]) -> (TempDir, PathBuf) {
1058        let tmp = TempDir::new().unwrap();
1059        let path = tmp.path().join("abc123.jsonl");
1060        let mut f = fs::File::create(&path).unwrap();
1061        for l in lines {
1062            writeln!(f, "{}", l).unwrap();
1063        }
1064        (tmp, path)
1065    }
1066
1067    fn assistant_line(id: &str, model: &str, ts: &str, inp: u64, out: u64, cr: u64, cw: u64) -> String {
1068        format!(
1069            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}}}}}}}"#
1070        )
1071    }
1072
1073    #[test]
1074    fn parse_deduplicates_repeated_message_ids() {
1075        // Mirrors real Claude Code JSONL: each assistant message is written 2-3
1076        // times with identical usage as the stream progresses.
1077        let a1 = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 15, 1463, 16397, 25661);
1078        let a1_dup = a1.clone();
1079        let a2 = assistant_line("msg_02", "claude-opus-4-6", "2026-04-14T08:34:37.200Z", 10, 401, 42058, 1668);
1080        let a2_dup = a2.clone();
1081        let a2_dup2 = a2.clone();
1082        let (_tmp, path) = write_jsonl(&[&a1, &a1_dup, &a2, &a2_dup, &a2_dup2]);
1083
1084        let entries = parse_jsonl_entries(&path).unwrap().entries;
1085        assert_eq!(entries.len(), 2, "should dedup by message.id");
1086        assert_eq!(entries[0].input_tokens, 15);
1087        assert_eq!(entries[0].output_tokens, 1463);
1088        assert_eq!(entries[1].input_tokens, 10);
1089    }
1090
1091    #[test]
1092    fn parse_captures_entrypoint_from_assistant_row() {
1093        // Headless `claude -p` writes JSONL with entrypoint="sdk-cli" on every
1094        // assistant row. Interactive Claude Code writes "cli". A row missing
1095        // the field (legacy/malformed) yields None.
1096        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}}}"#;
1097        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}}}"#;
1098        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}}}"#;
1099        let (_tmp, path) = write_jsonl(&[headless, interactive, legacy]);
1100
1101        let entries = parse_jsonl_entries(&path).unwrap().entries;
1102        assert_eq!(entries.len(), 3);
1103        assert_eq!(entries[0].entrypoint.as_deref(), Some("sdk-cli"));
1104        assert_eq!(entries[1].entrypoint.as_deref(), Some("cli"));
1105        assert_eq!(entries[2].entrypoint, None);
1106    }
1107
1108    #[test]
1109    fn parse_skips_non_assistant_entries() {
1110        let lines = [
1111            r#"{"type":"user","message":{"role":"user","content":"hi"}}"#.to_string(),
1112            r#"{"type":"permission-mode"}"#.to_string(),
1113            r#"{"type":"file-history-snapshot"}"#.to_string(),
1114            r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z"}"#.to_string(),
1115            assistant_line("msg_01", "claude-sonnet-4-6", "2026-04-14T08:28:00.000Z", 100, 200, 0, 0),
1116        ];
1117        let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
1118        let (_tmp, path) = write_jsonl(&line_refs);
1119
1120        let entries = parse_jsonl_entries(&path).unwrap().entries;
1121        assert_eq!(entries.len(), 1);
1122        assert_eq!(entries[0].model, "claude-sonnet-4-6");
1123    }
1124
1125    #[test]
1126    fn parse_tolerates_malformed_lines_and_empty_lines() {
1127        let good = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1128        let lines = vec![
1129            "",
1130            "not json at all",
1131            r#"{"type":"assistant","message":null}"#,
1132            r#"{"type":"assistant","message":{"id":null}}"#,
1133            &good,
1134            "   ",
1135            r#"{"broken":"trailing"#,
1136        ];
1137        let (_tmp, path) = write_jsonl(&lines);
1138
1139        let entries = parse_jsonl_entries(&path).unwrap().entries;
1140        assert_eq!(entries.len(), 1);
1141    }
1142
1143    #[test]
1144    fn parse_extracts_session_id_from_file_stem() {
1145        let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1146        let (_tmp, path) = write_jsonl(&[&line]);
1147        let entries = parse_jsonl_entries(&path).unwrap().entries;
1148        assert_eq!(entries[0].session_id, "abc123");
1149    }
1150
1151    #[test]
1152    fn parse_parses_iso_timestamps() {
1153        let line = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 1, 1, 0, 0);
1154        let (_tmp, path) = write_jsonl(&[&line]);
1155        let entries = parse_jsonl_entries(&path).unwrap().entries;
1156        // 2026-04-14T08:27:38.895Z → 1776155258895 ms
1157        assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1158    }
1159
1160    #[test]
1161    fn parse_skips_synthetic_model_rows() {
1162        // Claude Code / Agent SDK writes `model: "<synthetic>"` for internal
1163        // routing (skill listings, queue ops). These must not count as turns.
1164        let real = assistant_line("msg_real", "claude-opus-4-6", "2026-04-11T00:27:38.895Z", 15, 100, 0, 0);
1165        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}}}"#;
1166        let (_tmp, path) = write_jsonl(&[&real, synthetic]);
1167        let entries = parse_jsonl_entries(&path).unwrap().entries;
1168        assert_eq!(entries.len(), 1);
1169        assert_eq!(entries[0].model, "claude-opus-4-6");
1170    }
1171
1172    #[test]
1173    fn parse_handles_missing_usage_fields() {
1174        let line = r#"{"type":"assistant","timestamp":"2026-04-14T08:27:38.895Z","message":{"id":"msg_01","model":"claude-opus-4-6"}}"#;
1175        let (_tmp, path) = write_jsonl(&[line]);
1176        let entries = parse_jsonl_entries(&path).unwrap().entries;
1177        assert_eq!(entries.len(), 1);
1178        assert_eq!(entries[0].input_tokens, 0);
1179        assert_eq!(entries[0].cache_read_tokens, 0);
1180    }
1181
1182    #[test]
1183    fn reduce_computes_primary_model_by_mode() {
1184        let e = |m: &str| UsageEntry {
1185            session_id: "s".into(),
1186            timestamp_ms: 0,
1187            model: m.into(),
1188            input_tokens: 1,
1189            output_tokens: 1,
1190            cache_read_tokens: 0,
1191            cache_write_tokens: 0,
1192            cache_write_1h_tokens: 0,
1193            web_search_requests: 0,
1194            speed: None,
1195            tool_uses: Vec::new(),
1196            text_chars: 0,
1197            activity: Activity::General,
1198            entrypoint: None,
1199        };
1200        let entries = vec![
1201            e("claude-opus-4-6"),
1202            e("claude-opus-4-6"),
1203            e("claude-opus-4-6"),
1204            e("claude-sonnet-4-6"),
1205        ];
1206        let u = reduce_session(&entries, "", "sid").unwrap();
1207        assert_eq!(u.model, "claude-opus-4-6");
1208        assert_eq!(u.message_count, 4);
1209    }
1210
1211    #[test]
1212    fn reduce_uses_launched_model_for_context_window() {
1213        let entries = vec![UsageEntry {
1214            session_id: "s".into(),
1215            timestamp_ms: 0,
1216            // JSONL strips the [1m] suffix
1217            model: "claude-opus-4-6".into(),
1218            input_tokens: 1000,
1219            output_tokens: 0,
1220            cache_read_tokens: 0,
1221            cache_write_tokens: 0,
1222            cache_write_1h_tokens: 0,
1223            web_search_requests: 0,
1224            speed: None,
1225            tool_uses: Vec::new(),
1226            text_chars: 0,
1227            activity: Activity::General,
1228            entrypoint: None,
1229        }];
1230        let with_1m = reduce_session(&entries, "claude-opus-4-6[1m]", "sid").unwrap();
1231        let without = reduce_session(&entries, "", "sid").unwrap();
1232        assert_eq!(with_1m.context_window_size, 1_000_000);
1233        assert_eq!(without.context_window_size, 200_000);
1234    }
1235
1236    #[test]
1237    fn reduce_context_pct_uses_last_entry_only() {
1238        // Claude's context window reflects the LAST turn's input, not the sum.
1239        let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1240            session_id: "s".into(),
1241            timestamp_ms: 0,
1242            model: "claude-opus-4-6".into(),
1243            input_tokens: input,
1244            output_tokens: 0,
1245            cache_read_tokens: cr,
1246            cache_write_tokens: cw,
1247            cache_write_1h_tokens: 0,
1248            web_search_requests: 0,
1249            speed: None,
1250            tool_uses: Vec::new(),
1251            text_chars: 0,
1252            activity: Activity::General,
1253            entrypoint: None,
1254        };
1255        let entries = vec![
1256            mk(1000, 0, 0),
1257            mk(500, 0, 0),
1258            mk(5, 160_000, 0), // last: 160_005 / 200_000 = 80.0025%
1259        ];
1260        let u = reduce_session(&entries, "", "sid").unwrap();
1261        assert_eq!(u.current_context_tokens, 160_005);
1262        assert!((u.context_window_used_pct - 80.0025).abs() < 0.01);
1263    }
1264
1265    #[test]
1266    fn reduce_cache_hit_pct() {
1267        let e = |input: u64, cr: u64, cw: u64| UsageEntry {
1268            session_id: "s".into(),
1269            timestamp_ms: 0,
1270            model: "claude-opus-4-6".into(),
1271            input_tokens: input,
1272            output_tokens: 1,
1273            cache_read_tokens: cr,
1274            cache_write_tokens: cw,
1275            cache_write_1h_tokens: 0,
1276            web_search_requests: 0,
1277            speed: None,
1278            tool_uses: Vec::new(),
1279            text_chars: 0,
1280            activity: Activity::General,
1281            entrypoint: None,
1282        };
1283        // 3 fresh + 97 cache_read + 0 cache_write → 97 / 100 = 97%
1284        let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1285        assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1286
1287        // Cache writes count as NOT a hit: 10 fresh + 80 read + 10 write → 80%
1288        let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1289        assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1290
1291        // zero activity → 0 (not NaN)
1292        let u = reduce_session(&[e(0, 0, 0)], "", "sid").unwrap();
1293        assert_eq!(u.cache_hit_pct, 0.0);
1294    }
1295
1296    #[test]
1297    fn reduce_total_context_sent_is_cumulative() {
1298        let mk = |input: u64, cr: u64, cw: u64| UsageEntry {
1299            session_id: "s".into(),
1300            timestamp_ms: 0,
1301            model: "claude-opus-4-6".into(),
1302            input_tokens: input,
1303            output_tokens: 0,
1304            cache_read_tokens: cr,
1305            cache_write_tokens: cw,
1306            cache_write_1h_tokens: 0,
1307            web_search_requests: 0,
1308            speed: None,
1309            tool_uses: Vec::new(),
1310            text_chars: 0,
1311            activity: Activity::General,
1312            entrypoint: None,
1313        };
1314        let u = reduce_session(
1315            &[mk(15, 16_397, 25_661), mk(10, 42_058, 1_668)],
1316            "",
1317            "sid",
1318        )
1319        .unwrap();
1320        assert_eq!(u.total_context_sent, 15 + 16_397 + 25_661 + 10 + 42_058 + 1_668);
1321    }
1322
1323    #[test]
1324    fn reduce_cost_matches_opus_pricing() {
1325        // Opus: $5/$25 input/output, $0.50 read, $6.25 write per 1M tokens
1326        let entries = vec![UsageEntry {
1327            session_id: "s".into(),
1328            timestamp_ms: 0,
1329            model: "claude-opus-4-6".into(),
1330            input_tokens: 1_000_000,   // $5
1331            output_tokens: 1_000_000,  // $25
1332            cache_read_tokens: 1_000_000, // $0.50
1333            cache_write_tokens: 1_000_000, // $6.25
1334            cache_write_1h_tokens: 0,
1335            web_search_requests: 0,
1336            speed: None,
1337            tool_uses: Vec::new(),
1338            text_chars: 0,
1339            activity: Activity::General,
1340            entrypoint: None,
1341        }];
1342        let u = reduce_session(&entries, "", "sid").unwrap();
1343        assert!((u.estimated_cost_usd - 36.75).abs() < 0.001);
1344    }
1345
1346    #[test]
1347    fn reduce_empty_returns_none() {
1348        assert!(reduce_session(&[], "", "sid").is_none());
1349    }
1350
1351    // ---------- content extraction ----------
1352
1353    fn assistant_with_content(id: &str, ts: &str, content_json: &str) -> String {
1354        format!(
1355            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}}}}}"#
1356        )
1357    }
1358
1359    #[test]
1360    fn parse_extracts_tool_use_names() {
1361        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"}}]"#;
1362        let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1363        let (_tmp, path) = write_jsonl(&[&line]);
1364        let entries = parse_jsonl_entries(&path).unwrap().entries;
1365        assert_eq!(entries.len(), 1);
1366        assert_eq!(entries[0].tool_uses, vec!["Edit".to_string(), "Read".to_string()]);
1367    }
1368
1369    #[test]
1370    fn parse_captures_text_char_count() {
1371        let content = r#"[{"type":"text","text":"Hello world"},{"type":"text","text":"!"}]"#;
1372        let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1373        let (_tmp, path) = write_jsonl(&[&line]);
1374        let entries = parse_jsonl_entries(&path).unwrap().entries;
1375        assert_eq!(entries[0].text_chars, 12); // "Hello world" + "!"
1376    }
1377
1378    #[test]
1379    fn parse_handles_missing_content_gracefully() {
1380        // Most existing JSONL rows have no `content` field at all (older
1381        // Claude Code versions, or when we parse a row before content arrives).
1382        let line = assistant_line("msg_1", "claude-opus-4-6", "2026-04-14T08:27:38.895Z", 5, 10, 0, 0);
1383        let (_tmp, path) = write_jsonl(&[&line]);
1384        let entries = parse_jsonl_entries(&path).unwrap().entries;
1385        assert_eq!(entries.len(), 1);
1386        assert!(entries[0].tool_uses.is_empty());
1387        assert_eq!(entries[0].text_chars, 0);
1388        assert_eq!(entries[0].activity, Activity::General);
1389    }
1390
1391    #[test]
1392    fn parse_classifies_edit_plus_debug_as_debugging() {
1393        // Real-world flow: user asks to fix a bug, assistant runs Edit.
1394        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();
1395        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"login.rs"}}]"#;
1396        let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1397        let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1398        let entries = parse_jsonl_entries(&path).unwrap().entries;
1399        assert_eq!(entries.len(), 1);
1400        assert_eq!(entries[0].activity, Activity::Debugging);
1401    }
1402
1403    #[test]
1404    fn parse_classifies_bash_pytest_as_testing() {
1405        let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"run the tests"}}"#.to_string();
1406        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/unit"}}]"#;
1407        let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1408        let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1409        let entries = parse_jsonl_entries(&path).unwrap().entries;
1410        assert_eq!(entries[0].activity, Activity::Testing);
1411    }
1412
1413    #[test]
1414    fn parse_classifies_bash_git_as_gitops() {
1415        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"git push origin main"}}]"#;
1416        let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1417        let (_tmp, path) = write_jsonl(&[&line]);
1418        let entries = parse_jsonl_entries(&path).unwrap().entries;
1419        assert_eq!(entries[0].activity, Activity::GitOps);
1420    }
1421
1422    #[test]
1423    fn parse_tool_result_row_does_not_overwrite_user_text() {
1424        // Sequence: user says "refactor the store", assistant runs a tool, the
1425        // injected tool_result row is also type="user", then another assistant
1426        // follow-up. The follow-up should still attribute to the original
1427        // "refactor" intent — tool_result rows must NOT overwrite last_user_text.
1428        let user_real = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"refactor the usage store"}}"#.to_string();
1429        let asst1 = assistant_with_content(
1430            "msg_1",
1431            "2026-04-14T08:27:35.000Z",
1432            r#"[{"type":"tool_use","id":"tu_1","name":"Read","input":{"file_path":"store.ts"}}]"#,
1433        );
1434        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();
1435        let asst2 = assistant_with_content(
1436            "msg_2",
1437            "2026-04-14T08:27:40.000Z",
1438            r#"[{"type":"tool_use","id":"tu_2","name":"Edit","input":{"file_path":"store.ts"}}]"#,
1439        );
1440        let (_tmp, path) = write_jsonl(&[&user_real, &asst1, &tool_result, &asst2]);
1441        let entries = parse_jsonl_entries(&path).unwrap().entries;
1442        assert_eq!(entries.len(), 2);
1443        // First assistant: Read only → Exploration
1444        assert_eq!(entries[0].activity, Activity::Exploration);
1445        // Second assistant: Edit after "refactor" intent → Refactor (not Coding),
1446        // proving the tool_result row didn't wipe last_user_text.
1447        assert_eq!(entries[1].activity, Activity::Refactor);
1448    }
1449
1450    #[test]
1451    fn parse_classifies_agent_turn_as_delegation() {
1452        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Agent","input":{"description":"explore"}}]"#;
1453        let line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1454        let (_tmp, path) = write_jsonl(&[&line]);
1455        let entries = parse_jsonl_entries(&path).unwrap().entries;
1456        assert_eq!(entries[0].activity, Activity::Delegation);
1457    }
1458
1459    #[test]
1460    fn parse_pure_text_turn_is_conversation() {
1461        let user_line = r#"{"type":"user","timestamp":"2026-04-14T08:27:30.000Z","message":{"role":"user","content":"thanks"}}"#.to_string();
1462        let content = r#"[{"type":"text","text":"You're welcome — anything else?"}]"#;
1463        let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1464        let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1465        let entries = parse_jsonl_entries(&path).unwrap().entries;
1466        assert_eq!(entries[0].activity, Activity::Conversation);
1467        assert!(entries[0].text_chars > 0);
1468    }
1469
1470    #[test]
1471    fn parse_user_array_content_extracts_text_blocks() {
1472        // Some Claude Code versions write user content as an array even for
1473        // plain text. Ensure we pull the text out, not skip the row.
1474        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();
1475        let content = r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{}}]"#;
1476        let asst_line = assistant_with_content("msg_1", "2026-04-14T08:27:38.895Z", content);
1477        let (_tmp, path) = write_jsonl(&[&user_line, &asst_line]);
1478        let entries = parse_jsonl_entries(&path).unwrap().entries;
1479        assert_eq!(entries[0].activity, Activity::Debugging);
1480    }
1481
1482    // ---------- split-row content merge (Claude Code one-block-per-row) ----------
1483
1484    /// Helper: builds an assistant row that carries the same token usage
1485    /// numbers as the other rows for this `id` (matches what Claude Code
1486    /// actually writes — every row of a message repeats the final usage block).
1487    fn assistant_split_row(id: &str, ts: &str, content_json: &str) -> String {
1488        format!(
1489            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}}}}}"#
1490        )
1491    }
1492
1493    #[test]
1494    fn parse_merges_tool_uses_across_split_content_rows() {
1495        // Claude Code 2.1.x emits one JSONL row per content block of an
1496        // assistant message: a `thinking` row, an optional `text` row, then
1497        // one row per `tool_use`. All rows share the same `message.id` and
1498        // the same `usage` block. The parser must merge tool_uses across rows
1499        // rather than dropping every row after the first.
1500        let thinking = assistant_split_row(
1501            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1502            "2026-05-13T09:09:03.500Z",
1503            r#"[{"type":"thinking","thinking":"plan"}]"#,
1504        );
1505        let text = assistant_split_row(
1506            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1507            "2026-05-13T09:09:03.550Z",
1508            r#"[{"type":"text","text":"Running tools in parallel."}]"#,
1509        );
1510        let tu1 = assistant_split_row(
1511            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1512            "2026-05-13T09:09:03.600Z",
1513            r#"[{"type":"tool_use","id":"tu_1","name":"mcp__example__list_configs","input":{"area":"rules"}}]"#,
1514        );
1515        let tu2 = assistant_split_row(
1516            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1517            "2026-05-13T09:09:03.700Z",
1518            r#"[{"type":"tool_use","id":"tu_2","name":"mcp__example__get_report","input":{}}]"#,
1519        );
1520        let tu3 = assistant_split_row(
1521            "msg_01M6AvUVpAFjzSWcXeyGzUwV",
1522            "2026-05-13T09:09:03.800Z",
1523            r#"[{"type":"tool_use","id":"tu_3","name":"Edit","input":{"file_path":"a.rs"}}]"#,
1524        );
1525        let (_tmp, path) = write_jsonl(&[&thinking, &text, &tu1, &tu2, &tu3]);
1526
1527        let entries = parse_jsonl_entries(&path).unwrap().entries;
1528        assert_eq!(entries.len(), 1, "still one entry per message.id");
1529
1530        let e = &entries[0];
1531        assert_eq!(
1532            e.tool_uses,
1533            vec![
1534                "mcp__example__list_configs".to_string(),
1535                "mcp__example__get_report".to_string(),
1536                "Edit".to_string(),
1537            ],
1538            "all tool_use names merged in row order"
1539        );
1540        assert!(e.text_chars > 0, "text from the text row is preserved");
1541
1542        // Billing fields come from the first row (they repeat identically, so
1543        // first == last in practice). Importantly, the merged content must
1544        // NOT cause tokens to multiply.
1545        assert_eq!(e.input_tokens, 6);
1546        assert_eq!(e.output_tokens, 746);
1547        assert_eq!(e.cache_read_tokens, 21859);
1548        assert_eq!(e.cache_write_tokens, 33027);
1549    }
1550
1551    #[test]
1552    fn parse_split_row_classifies_against_merged_content() {
1553        // Bug pre-fix: first row is `thinking`, so `tool_uses` was empty and
1554        // activity classified as Conversation/General. After the fix the merged
1555        // content includes Edit, and a "fix the bug" user intent must route to
1556        // Debugging.
1557        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();
1558        let thinking = assistant_split_row(
1559            "msg_split",
1560            "2026-05-13T09:09:01.000Z",
1561            r#"[{"type":"thinking","thinking":"investigating"}]"#,
1562        );
1563        let edit = assistant_split_row(
1564            "msg_split",
1565            "2026-05-13T09:09:02.000Z",
1566            r#"[{"type":"tool_use","id":"tu_1","name":"Edit","input":{"file_path":"auth.rs"}}]"#,
1567        );
1568        let (_tmp, path) = write_jsonl(&[&user, &thinking, &edit]);
1569
1570        let entries = parse_jsonl_entries(&path).unwrap().entries;
1571        assert_eq!(entries.len(), 1);
1572        assert_eq!(entries[0].activity, Activity::Debugging);
1573        assert_eq!(entries[0].tool_uses, vec!["Edit".to_string()]);
1574    }
1575
1576    #[test]
1577    fn parse_split_row_bash_commands_feed_classifier() {
1578        // bash_cmds accumulate across rows too — pytest in a separate row
1579        // from the assistant's text must still classify as Testing.
1580        let user = r#"{"type":"user","timestamp":"2026-05-13T09:09:00.000Z","message":{"role":"user","content":"run tests"}}"#.to_string();
1581        let text = assistant_split_row(
1582            "msg_bash",
1583            "2026-05-13T09:09:01.000Z",
1584            r#"[{"type":"text","text":"Running the unit tests."}]"#,
1585        );
1586        let bash = assistant_split_row(
1587            "msg_bash",
1588            "2026-05-13T09:09:02.000Z",
1589            r#"[{"type":"tool_use","id":"tu_1","name":"Bash","input":{"command":"pytest tests/"}}]"#,
1590        );
1591        let (_tmp, path) = write_jsonl(&[&user, &text, &bash]);
1592
1593        let entries = parse_jsonl_entries(&path).unwrap().entries;
1594        assert_eq!(entries[0].activity, Activity::Testing);
1595    }
1596
1597    // ---------- hook extraction ----------
1598
1599    #[test]
1600    fn parse_extracts_hook_success_events() {
1601        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}}"#;
1602        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}}"#;
1603        // hook_system_message should be ignored (no execution data)
1604        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"}}"#;
1605        let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-15T18:08:51.000Z", 5, 10, 0, 0);
1606        let (_tmp, path) = write_jsonl(&[hook_line, hook_fail, hook_msg, &asst]);
1607
1608        let parsed = parse_jsonl_entries(&path).unwrap();
1609        assert_eq!(parsed.entries.len(), 1, "assistant entries unaffected");
1610        assert_eq!(parsed.hooks.len(), 2, "two hook_success events");
1611
1612        assert_eq!(parsed.hooks[0].hook_name, "PostToolUse:Edit");
1613        assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1614        assert_eq!(parsed.hooks[0].command, "verify-edit.sh");
1615        assert_eq!(parsed.hooks[0].exit_code, 0);
1616        assert_eq!(parsed.hooks[0].duration_ms, 90);
1617
1618        assert_eq!(parsed.hooks[0].tool_use_id.as_deref(), Some("tu_abc"));
1619
1620        assert_eq!(parsed.hooks[1].hook_name, "PreToolUse:Bash");
1621        assert_eq!(parsed.hooks[1].exit_code, 1);
1622        assert_eq!(parsed.hooks[1].duration_ms, 250);
1623        assert_eq!(parsed.hooks[1].tool_use_id.as_deref(), Some("tu_def"));
1624    }
1625
1626    #[test]
1627    fn parse_ignores_attachment_without_hook_data() {
1628        // Non-hook attachments (e.g. file-history-snapshot) should be skipped.
1629        let non_hook = r#"{"type":"attachment","timestamp":"2026-04-14T08:27:26.236Z","attachment":{"type":"file_content","path":"foo.rs"}}"#;
1630        let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-14T08:28:00.000Z", 1, 1, 0, 0);
1631        let (_tmp, path) = write_jsonl(&[non_hook, &asst]);
1632
1633        let parsed = parse_jsonl_entries(&path).unwrap();
1634        assert_eq!(parsed.entries.len(), 1);
1635        assert!(parsed.hooks.is_empty());
1636    }
1637
1638    #[test]
1639    fn snake_to_pascal_converts_correctly() {
1640        assert_eq!(snake_to_pascal("stop"), "Stop");
1641        assert_eq!(snake_to_pascal("pre_tool_use"), "PreToolUse");
1642        assert_eq!(snake_to_pascal("post_tool_use"), "PostToolUse");
1643        assert_eq!(snake_to_pascal("session_start"), "SessionStart");
1644    }
1645
1646    #[test]
1647    fn parse_extracts_system_hook_summary_v2() {
1648        // v2.1.119+ format: type=system, subtype=*_hook_summary
1649        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"}"#;
1650        let asst = assistant_line("msg_01", "claude-opus-4-6", "2026-04-25T09:06:13.000Z", 5, 10, 0, 0);
1651        let (_tmp, path) = write_jsonl(&[stop_summary, &asst]);
1652
1653        let parsed = parse_jsonl_entries(&path).unwrap();
1654        assert_eq!(parsed.entries.len(), 1);
1655        assert_eq!(parsed.hooks.len(), 1);
1656
1657        let h = &parsed.hooks[0];
1658        assert_eq!(h.hook_event, "Stop");
1659        assert_eq!(h.command, "Running build checks...");
1660        assert_eq!(h.duration_ms, 5227);
1661        assert_eq!(h.exit_code, 0, "no errors and not prevented = success");
1662        assert_eq!(h.tool_use_id.as_deref(), Some("tu_xyz"));
1663    }
1664
1665    #[test]
1666    fn parse_system_hook_summary_with_errors() {
1667        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"}"#;
1668        let asst = assistant_line("msg_02", "claude-opus-4-6", "2026-04-25T10:00:01.000Z", 1, 1, 0, 0);
1669        let (_tmp, path) = write_jsonl(&[summary, &asst]);
1670
1671        let parsed = parse_jsonl_entries(&path).unwrap();
1672        assert_eq!(parsed.hooks.len(), 1);
1673        assert_eq!(parsed.hooks[0].exit_code, 1, "errors or prevented = failure");
1674    }
1675
1676    #[test]
1677    fn parse_system_hook_summary_multiple_hooks() {
1678        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"}"#;
1679        let asst = assistant_line("msg_03", "claude-opus-4-6", "2026-04-25T11:00:01.000Z", 1, 1, 0, 0);
1680        let (_tmp, path) = write_jsonl(&[summary, &asst]);
1681
1682        let parsed = parse_jsonl_entries(&path).unwrap();
1683        assert_eq!(parsed.hooks.len(), 2);
1684        assert_eq!(parsed.hooks[0].command, "tsc --noEmit");
1685        assert_eq!(parsed.hooks[0].duration_ms, 1200);
1686        assert_eq!(parsed.hooks[1].command, "cargo check");
1687        assert_eq!(parsed.hooks[1].duration_ms, 3500);
1688        assert_eq!(parsed.hooks[0].tool_use_id, parsed.hooks[1].tool_use_id);
1689    }
1690
1691    #[test]
1692    fn hook_name_label_sanitizes_long_multiline_commands() {
1693        // /goal-style stop hook: command is a multi-line goal statement.
1694        // The synthesized hook_name must NOT include the full body — otherwise
1695        // every distinct goal creates a unique bucket and breaks the Usage page.
1696        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.";
1697        let escaped = serde_json::to_string(long_cmd).unwrap();
1698        let summary = format!(
1699            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"}}"#
1700        );
1701        let asst = assistant_line("msg_goal", "claude-opus-4-6", "2026-04-25T12:00:01.000Z", 1, 1, 0, 0);
1702        let (_tmp, path) = write_jsonl(&[&summary, &asst]);
1703
1704        let parsed = parse_jsonl_entries(&path).unwrap();
1705        assert_eq!(parsed.hooks.len(), 1);
1706        let h = &parsed.hooks[0];
1707
1708        assert_eq!(h.command, long_cmd, "full command preserved on the entry");
1709        assert!(h.hook_name.starts_with("Stop:"), "name still scoped by event");
1710        // Total label length is bounded: event prefix + ":" + max 64 chars + 1-char ellipsis.
1711        assert!(h.hook_name.chars().count() <= "Stop:".len() + 65);
1712        assert!(h.hook_name.ends_with('…'), "long commands get ellipsis: {}", h.hook_name);
1713        assert!(!h.hook_name.contains('\n'), "no newlines in bucket key");
1714    }
1715
1716    #[test]
1717    fn hook_name_label_preserves_short_commands() {
1718        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"}"#;
1719        let asst = assistant_line("msg_short", "claude-opus-4-6", "2026-04-25T13: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[0].hook_name, "Stop:cargo check");
1724    }
1725
1726    #[test]
1727    fn parse_handles_both_formats_in_same_file() {
1728        // Old v2.1.101 attachment format
1729        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}}"#;
1730        // New v2.1.119 system format
1731        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"}"#;
1732        let asst = assistant_line("msg_both", "claude-opus-4-6", "2026-04-25T09:07:00.000Z", 1, 1, 0, 0);
1733        let (_tmp, path) = write_jsonl(&[old_hook, new_hook, &asst]);
1734
1735        let parsed = parse_jsonl_entries(&path).unwrap();
1736        assert_eq!(parsed.hooks.len(), 2, "both formats parsed");
1737        assert_eq!(parsed.hooks[0].hook_event, "PostToolUse");
1738        assert_eq!(parsed.hooks[1].hook_event, "Stop");
1739    }
1740
1741    // ---------- tiered cache / fast mode / web search pricing ----------
1742
1743    #[allow(clippy::too_many_arguments)]
1744    fn entry_for_cost(
1745        model: &str,
1746        input: u64,
1747        output: u64,
1748        cr: u64,
1749        cw_total: u64,
1750        cw_1h: u64,
1751        web: u64,
1752        speed: Option<&str>,
1753    ) -> UsageEntry {
1754        UsageEntry {
1755            session_id: "s".into(),
1756            timestamp_ms: 0,
1757            model: model.into(),
1758            input_tokens: input,
1759            output_tokens: output,
1760            cache_read_tokens: cr,
1761            cache_write_tokens: cw_total,
1762            cache_write_1h_tokens: cw_1h,
1763            web_search_requests: web,
1764            speed: speed.map(|s| s.to_string()),
1765            tool_uses: Vec::new(),
1766            text_chars: 0,
1767            activity: Activity::General,
1768            entrypoint: None,
1769        }
1770    }
1771
1772    #[test]
1773    fn fable_pricing_all_token_classes() {
1774        // Fable 5: $10 in / $50 out / $1 read / $12.50 5m-write / $20 1h-write per MTok.
1775        let input = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, None);
1776        assert!((cost_for_entry(&input) - 10.0).abs() < 1e-6);
1777        let output = entry_for_cost("claude-fable-5", 0, 1_000_000, 0, 0, 0, 0, None);
1778        assert!((cost_for_entry(&output) - 50.0).abs() < 1e-6);
1779        let read = entry_for_cost("claude-fable-5", 0, 0, 1_000_000, 0, 0, 0, None);
1780        assert!((cost_for_entry(&read) - 1.0).abs() < 1e-6);
1781        let w5m = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 0, 0, None);
1782        assert!((cost_for_entry(&w5m) - 12.5).abs() < 1e-6);
1783        let w1h = entry_for_cost("claude-fable-5", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1784        assert!((cost_for_entry(&w1h) - 20.0).abs() < 1e-6);
1785    }
1786
1787    #[test]
1788    fn fable_has_no_fast_premium() {
1789        // Fast mode is Opus-only; an (unexpected) fast-tagged Fable turn must
1790        // not be multiplied — never overcount on a guess.
1791        let fast = entry_for_cost("claude-fable-5", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1792        assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1793    }
1794
1795    #[test]
1796    fn fable_context_window_is_natively_1m() {
1797        // No `[1m]` suffix variant exists for Fable — 1M is the default.
1798        assert_eq!(context_window_for_model("claude-fable-5"), 1_000_000);
1799        assert_eq!(context_window_for_model("claude-opus-4-8"), 200_000);
1800        assert_eq!(context_window_for_model("claude-opus-4-8[1m]"), 1_000_000);
1801    }
1802
1803    #[test]
1804    fn cost_splits_5m_and_1h_cache_writes() {
1805        // Opus: 5m write $6.25/M, 1h write $10/M.
1806        let all_1h = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1807        assert!((cost_for_entry(&all_1h) - 10.0).abs() < 1e-6);
1808        let all_5m = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 0, 0, None);
1809        assert!((cost_for_entry(&all_5m) - 6.25).abs() < 1e-6);
1810        // 400k 1h + 600k 5m = 0.4*10 + 0.6*6.25 = $7.75.
1811        let mixed = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 400_000, 0, None);
1812        assert!((cost_for_entry(&mixed) - 7.75).abs() < 1e-6);
1813    }
1814
1815    #[test]
1816    fn cost_adds_flat_web_search_fee() {
1817        // 3 web searches → $0.03, independent of tokens.
1818        let e = entry_for_cost("claude-sonnet-4-6", 0, 0, 0, 0, 0, 3, None);
1819        assert!((cost_for_entry(&e) - 0.03).abs() < 1e-9);
1820    }
1821
1822    #[test]
1823    fn fast_mode_multiplier_scales_token_cost_not_web_fee() {
1824        // Opus 4.8 fast = 2x: 1M input $5 → $10.
1825        let std = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("standard"));
1826        assert!((cost_for_entry(&std) - 5.0).abs() < 1e-6);
1827        let fast = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1828        assert!((cost_for_entry(&fast) - 10.0).abs() < 1e-6);
1829        // Opus 4.6 fast = 6x → $30.
1830        let fast46 = entry_for_cost("claude-opus-4-6", 1_000_000, 0, 0, 0, 0, 0, Some("fast"));
1831        assert!((cost_for_entry(&fast46) - 30.0).abs() < 1e-6);
1832        // Web fee is flat even under fast: $10 token + $0.01 search.
1833        let fast_web = entry_for_cost("claude-opus-4-8", 1_000_000, 0, 0, 0, 0, 1, Some("fast"));
1834        assert!((cost_for_entry(&fast_web) - 10.01).abs() < 1e-6);
1835    }
1836
1837    #[test]
1838    fn fast_multiplier_only_for_known_opus() {
1839        assert_eq!(fast_multiplier_for_model("claude-opus-4-8"), 2.0);
1840        assert_eq!(fast_multiplier_for_model("claude-opus-4-7"), 6.0);
1841        assert_eq!(fast_multiplier_for_model("claude-opus-4-6"), 6.0);
1842        assert_eq!(fast_multiplier_for_model("claude-sonnet-4-6"), 1.0);
1843        // Unknown future model → no premium (never overcount on a guess).
1844        assert_eq!(fast_multiplier_for_model("claude-opus-5-0"), 1.0);
1845    }
1846
1847    #[test]
1848    fn parse_extracts_cache_split_speed_and_web_search() {
1849        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"}}}"#;
1850        let (_tmp, path) = write_jsonl(&[line]);
1851        let entries = parse_jsonl_entries(&path).unwrap().entries;
1852        assert_eq!(entries.len(), 1);
1853        let e = &entries[0];
1854        assert_eq!(e.cache_write_tokens, 40702, "flat total preserved (== 5m+1h)");
1855        assert_eq!(e.cache_write_1h_tokens, 40002);
1856        assert_eq!(e.web_search_requests, 2);
1857        assert_eq!(e.speed.as_deref(), Some("fast"));
1858    }
1859
1860    #[test]
1861    fn parse_cache_write_takes_max_of_flat_and_split() {
1862        // Nested split sums higher than the flat field → prefer the split.
1863        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}}}}"#;
1864        let (_tmp, path) = write_jsonl(&[line]);
1865        let e = &parse_jsonl_entries(&path).unwrap().entries[0];
1866        assert_eq!(e.cache_write_tokens, 1400, "max(100, 500+900)");
1867        assert_eq!(e.cache_write_1h_tokens, 900);
1868    }
1869
1870    #[test]
1871    fn parse_missing_nested_cache_falls_back_to_flat() {
1872        // Older rows: only the flat field → 1h = 0 (all treated as 5m), no speed/web.
1873        let line = assistant_line("msg_3", "claude-opus-4-8", "2026-05-29T10:00:00.000Z", 5, 10, 0, 30000);
1874        let (_tmp, path) = write_jsonl(&[&line]);
1875        let e = &parse_jsonl_entries(&path).unwrap().entries[0];
1876        assert_eq!(e.cache_write_tokens, 30000);
1877        assert_eq!(e.cache_write_1h_tokens, 0);
1878        assert_eq!(e.web_search_requests, 0);
1879        assert_eq!(e.speed, None);
1880    }
1881
1882    #[test]
1883    fn reduce_session_prices_1h_cache_premium() {
1884        // 1M of 1h cache writes on opus = $10, not the old single-rate $6.25.
1885        let e = entry_for_cost("claude-opus-4-8", 0, 0, 0, 1_000_000, 1_000_000, 0, None);
1886        let u = reduce_session(&[e], "", "sid").unwrap();
1887        assert!((u.estimated_cost_usd - 10.0).abs() < 1e-6);
1888    }
1889}