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/// `(major, minor)` of the version that follows `family` in a
30/// `claude-<family>-<major>[-<minor>][-<yyyymmdd>]` id, or `None` when no
31/// parseable version follows it (aliases like `"fable"`, legacy
32/// `claude-3-5-sonnet-…` forms, `<synthetic>`).
33///
34/// An 8-digit segment is a date snapshot, not a minor version, so
35/// `claude-sonnet-5-20260701` is `(5, 0)` while `claude-haiku-4-5-20251001`
36/// is `(4, 5)`. Callers compare with `>=` so an unrecognised future version
37/// prices as the newest tier we know about rather than the oldest.
38fn 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    // Legacy ids put the version *before* the family (`claude-3-5-sonnet-…`),
46    // so what follows is a date snapshot, not a version. Reject it rather than
47    // reading 20241022 as major version 20241022.
48    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
60/// Hardcoded as of 2026-09. Requires a code change + release when Anthropic updates pricing.
61///
62/// Two families price by version, so the id must carry one to get the newer
63/// rate — an alias (`"fable"`, `"sonnet"`) prices as the older tier:
64/// - **Fable 5.1+** reads cache at 0.025× input ($0.25/MTok) instead of the
65///   standard 0.1× ($1/MTok) that Fable 5 pays. Everything else is identical.
66/// - **Sonnet 5+** is $2/$10; Sonnet 4.6 and earlier stay at $3/$15. The $2/$10
67///   launch price became Sonnet 5's standard price on 2026-09-01 — the
68///   scheduled increase to $3/$15 was cancelled.
69pub fn pricing_for_model(model: &str) -> ModelPricing {
70    if model.contains("fable") {
71        // Fable 5 / 5.1: $10/$50 per MTok. Writes derive from input at the
72        // standard multipliers (1.25× 5m, 2× 1h); only the read rate moved.
73        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        // Sonnet 4.6 and earlier, or unknown
110        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
120/// Flat fee per Anthropic web-search server-tool request ($10 per 1,000 = $0.01).
121pub const WEB_SEARCH_COST_PER_REQUEST: f64 = 0.01;
122
123/// Fast-mode (research preview) cost multiplier, applied to the entire token
124/// cost line when `usage.speed == "fast"`. Fast mode is Opus-only and its
125/// premium varies by version (confirmed Anthropic pricing, 2026-09):
126/// - Opus 4.6 / 4.7 fast: 6× ($30/$150 vs $5/$25)
127/// - Opus 4.8 / 5 fast:   2× ($10/$50 vs $5/$25)
128///
129/// Caching multipliers stack on top of fast pricing, i.e. fast scales the whole
130/// per-token line (input, output, and the cache rates that derive from input).
131/// Unknown / unsupported models return 1.0 (no premium) so we never overcount
132/// on a guess — newer Opus releases default here until their multiplier is added.
133/// Fable has no fast-mode variant (fast is Opus-only) → 1.0.
134pub 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
144/// Single source of truth for per-turn cost (USD), used by both the live status
145/// bar (`reduce_session`) and the Usage page (`commands::usage`).
146///
147/// Splits cache-write tokens into 5-minute and 1-hour tiers, applies the
148/// fast-mode multiplier to the token line when the turn ran in fast mode, and
149/// adds the flat web-search fee (which is not a token cost and so is unaffected
150/// by the fast multiplier).
151pub 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    // Fable 5 is natively 1M — there is no `[1m]` suffix variant for it.
171    if model.contains("[1m]") || model.contains("fable") {
172        1_000_000
173    } else {
174        200_000
175    }
176}
177
178/// Flat row representing a single completed assistant turn.
179///
180/// One entry per unique `message.id`. Claude Code writes multiple JSONL
181/// entries per assistant message as the stream progresses (typically 2-3),
182/// all sharing the same `id` and final usage numbers — dedup keeps only the
183/// first occurrence.
184///
185/// Token field semantics (inherited from Anthropic's API):
186/// - `input_tokens`: *fresh* tokens in this turn's prompt that were NOT served
187///   from cache. Small when conversation is reused, large on cache misses.
188/// - `cache_read_tokens`: tokens served from the prompt cache (cheap).
189/// - `cache_write_tokens`: tokens written into the prompt cache (~25% premium).
190/// - `output_tokens`: tokens generated by the model.
191///
192/// "Total context sent" for a turn = input + cache_read + cache_write.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase")]
195pub struct UsageEntry {
196    pub session_id: String,
197    /// Unix epoch ms from the JSONL `timestamp` field. 0 if unparseable.
198    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    /// Total cache-write (creation) tokens for this turn: `max(legacy flat
204    /// `cache_creation_input_tokens`, ephemeral_5m + ephemeral_1h)`. The 1-hour
205    /// subset is broken out separately in `cache_write_1h_tokens` for pricing;
206    /// this total still drives cache-hit % and token displays.
207    pub cache_write_tokens: u64,
208    /// The 1-hour-TTL portion of `cache_write_tokens` (from
209    /// `usage.cache_creation.ephemeral_1h_input_tokens`). Billed at 2× input
210    /// vs the 5-minute tier's 1.25×. The 5-minute portion is the remainder.
211    #[serde(default)]
212    pub cache_write_1h_tokens: u64,
213    /// Anthropic web-search server-tool requests this turn
214    /// (`usage.server_tool_use.web_search_requests`). Billed flat at $0.01 each.
215    #[serde(default)]
216    pub web_search_requests: u64,
217    /// `usage.speed`: `"standard"` or `"fast"`. `Some("fast")` applies the
218    /// fast-mode cost multiplier (see `fast_multiplier_for_model`). `None` on
219    /// rows that predate the field.
220    #[serde(default)]
221    pub speed: Option<String>,
222    /// Names of tools this turn invoked (from `tool_use` content blocks).
223    /// Empty for pure-text turns. Duplicates preserved when the model called
224    /// the same tool multiple times in one turn.
225    pub tool_uses: Vec<String>,
226    /// Sum of `text` content block character counts for this turn. Zero means
227    /// a silent tool-only turn; non-zero means the model said something.
228    pub text_chars: u32,
229    /// Classified activity category (Coding, Debugging, Exploration, …).
230    /// See the [`crate::activity`] module for the decision tree.
231    pub activity: Activity,
232    /// JSONL `entrypoint` field captured from the first row of each message id.
233    /// `"cli"` = interactive Claude Code session. `"sdk-cli"` = headless
234    /// `claude -p` spawn (any Agent SDK consumer lands here). `None` for legacy
235    /// rows that predate the field. Useful for distinguishing interactive vs.
236    /// headless usage; also load-bearing once Anthropic splits Agent SDK
237    /// billing from interactive plans (announced June 2026).
238    pub entrypoint: Option<String>,
239}
240
241/// A single hook execution event from a `type: "attachment"` JSONL record
242/// where `attachment.type == "hook_success"`.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct HookExecution {
246    pub session_id: String,
247    pub timestamp_ms: u64,
248    /// Lifecycle phase: `PreToolUse`, `PostToolUse`, `SessionStart`, `Stop`, etc.
249    pub hook_event: String,
250    /// Full hook label including matcher: `PreToolUse:Bash`, `Stop`, etc.
251    pub hook_name: String,
252    /// The shell command that was executed.
253    pub command: String,
254    pub exit_code: i32,
255    pub duration_ms: u64,
256    /// The tool-use ID that triggered this hook (groups multiple hooks per tool call).
257    pub tool_use_id: Option<String>,
258}
259
260/// Combined result from parsing a single JSONL file.
261#[derive(Default)]
262pub struct ParsedJsonl {
263    pub entries: Vec<UsageEntry>,
264    pub hooks: Vec<HookExecution>,
265}
266
267/// Usage summary for a single session, returned to the frontend status bar.
268#[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    /// Last message's total input context (current context window usage).
281    pub current_context_tokens: u64,
282    /// Cumulative context sent (input + cache_read + cache_write).
283    pub total_context_sent: u64,
284    /// cache_read / (input + cache_read), in percent. 0 when no cache activity.
285    pub cache_hit_pct: f64,
286    pub message_count: u64,
287    pub session_id: String,
288}
289
290/// Minimal JSONL entry structure.
291#[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    // Fields for `type: "system"` hook summary records (v2.1.119+).
300    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    /// Origin of the session: `"cli"` (interactive Claude Code) or `"sdk-cli"`
309    /// (headless `claude -p` / Agent SDK). Present on every assistant + user row.
310    entrypoint: Option<String>,
311}
312
313#[derive(Deserialize)]
314struct MessageData {
315    id: Option<String>,
316    model: Option<String>,
317    usage: Option<UsageData>,
318    /// Raw content — assistant messages use `Vec<ContentBlock>`, user messages
319    /// use either a string or an array of blocks (including tool_results).
320    /// Kept as a raw Value so we can handle both shapes without maintaining
321    /// two parallel structs.
322    #[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    /// Legacy flat total of cache-creation tokens. Still emitted alongside the
332    /// nested `cache_creation` split; we take the max of the two for safety.
333    cache_creation_input_tokens: Option<u64>,
334    /// 5-minute / 1-hour cache-write split (Claude Code ≥ the 1h-cache rollout).
335    cache_creation: Option<CacheCreation>,
336    /// Server-tool request counts (web search / web fetch).
337    server_tool_use: Option<ServerToolUse>,
338    /// `"standard"` | `"fast"`. Drives the fast-mode cost multiplier.
339    speed: Option<String>,
340}
341
342/// Nested `usage.cache_creation` object splitting cache writes by TTL tier.
343#[derive(Deserialize, Default)]
344struct CacheCreation {
345    ephemeral_5m_input_tokens: Option<u64>,
346    ephemeral_1h_input_tokens: Option<u64>,
347}
348
349/// Nested `usage.server_tool_use` object. Only `web_search_requests` is billable
350/// ($0.01/req); `web_fetch_requests` is free and intentionally not priced.
351#[derive(Deserialize, Default)]
352struct ServerToolUse {
353    web_search_requests: Option<u64>,
354}
355
356/// Single hook execution in a `hookInfos` array (v2.1.119+ system records).
357#[derive(Deserialize)]
358#[serde(rename_all = "camelCase")]
359struct HookInfoEntry {
360    command: Option<String>,
361    duration_ms: Option<u64>,
362}
363
364/// Minimal deserialization of the `attachment` field on `type: "attachment"` rows.
365#[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/// Claude Code active session file in `{config_dir}/sessions/{pid}.json`.
380#[derive(Deserialize)]
381#[serde(rename_all = "camelCase")]
382struct ActiveSession {
383    session_id: String,
384    cwd: String,
385    started_at: u64,
386}
387
388/// Hard cap to avoid pathological parsing of a corrupted file.
389///
390/// Parsing is streaming (`BufReader::lines()`) so memory is O(1) per line —
391/// the cap only exists to keep a runaway file from blocking the UI. Real
392/// agent sessions (Task-tool sub-agents) frequently exceed 30MB, so the cap
393/// needs generous headroom.
394use super::MAX_JSONL_BYTES;
395
396/// Encode a project path the same way Claude Code does.
397///
398/// Windows: `S:\Loom` -> `S--Loom`, `C:\Users\Seven\foo` -> `C--Users-Seven-foo`
399/// macOS/Linux: `/Users/seven/Loom` -> `-Users-seven-Loom`, `/home/alice/proj` -> `-home-alice-proj`
400///
401/// The transform collapses any leading path separator(s) into a single leading
402/// dash, which is how Claude Code writes `~/.claude/projects/` directory names
403/// on Unix. Verified against ccusage's decoder (see GitHub issue
404/// anthropics/claude-code#19972 for the path-encoding discussion).
405pub 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
432/// Extracted signal from an assistant message's `content` array.
433struct AssistantContent {
434    tool_uses: Vec<String>,
435    bash_cmds: Vec<String>,
436    text_chars: u32,
437}
438
439/// Walk an assistant message's content blocks to pull out tool names, any
440/// bash-style commands (used by the classifier for Testing/GitOps/Build
441/// detection), and the total character count of `text` blocks.
442fn 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 this is a Bash-family tool, grab the command string so
462                    // the classifier can distinguish Testing / GitOps / Build.
463                    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                    // Saturating because a single turn's text can exceed u32
478                    // only in pathological cases (would be >4 GB of text).
479                    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
491/// Extract the human-typed portion of a user message's content, if any.
492///
493/// User rows in Claude Code JSONL carry two distinct shapes:
494/// - A plain string (`"content": "hello"`) — always a real user turn.
495/// - An array of content blocks, which may be:
496///   - `[{"type":"text","text":"..."}]` → real user turn
497///   - `[{"type":"tool_result",...}]` → injected tool output, NOT a user turn
498///   - Mixed → treated as a real user turn
499///
500/// Sanitize the per-hook `command` for use inside a synthesized `hook_name`
501/// bucket key. Takes the first line, collapses internal whitespace, and caps
502/// at 64 chars (with `…` suffix). The full command is preserved separately on
503/// the `HookExecution.command` field — this only affects the display label /
504/// aggregation key used by the Usage page and Refinery hook stats.
505///
506/// Without this, `/goal`-style hooks whose "command" is a multi-KB goal
507/// statement explode the hook-table layout AND defeat aggregation (one bucket
508/// per invocation).
509fn 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
520/// Convert `snake_case` to `PascalCase`: `"stop"` → `"Stop"`, `"pre_tool_use"` → `"PreToolUse"`.
521fn 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
538/// Returns `None` for tool-result-only rows so the rolling `last_user_text`
539/// stays pinned to the most recent actual user message.
540fn 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
571/// Per-message-id accumulator for content blocks that arrive split across
572/// multiple JSONL rows. See `parse_jsonl_entries` for the why.
573struct EntryAccumulator {
574    out_idx: usize,
575    bash_cmds: Vec<String>,
576    user_text_at_first: Option<String>,
577}
578
579/// Parse a single JSONL file into one `UsageEntry` per unique `message.id`.
580///
581/// Pure function: depends only on the file contents. Non-assistant entries,
582/// malformed lines, and missing IDs are skipped silently.
583///
584/// **Dedup is split by field semantics**, because Claude Code emits one JSONL
585/// row per assistant content block (thinking / text / each tool_use) all
586/// sharing the same `message.id`:
587///
588/// - Billing fields (token counts, model, timestamp, session_id) come from the
589///   **first** row seen for each id — they repeat identically across rows.
590/// - Content fields (`tool_uses`, `text_chars`, and the bash commands fed to
591///   the activity classifier) are **unioned across every row** with that id,
592///   then `activity` is classified once at the end using the merged content.
593///
594/// Folding only by `message.id` (as this parser did before 2026-05-13) drops
595/// every tool_use after the first row, which made the Toolkit and Usage tool
596/// counters undercount by an order of magnitude. The chat parser already
597/// uses `(message_id + content)` for the same reason — see CLAUDE.md.
598///
599/// Runs a rolling `last_user_text` cursor during the stream: each genuine
600/// `type: "user"` row updates it; the snapshot taken at the **first** row of
601/// each assistant message is used for activity classification.
602pub 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        // Update the rolling user-message cursor on genuine user turns.
646        // Tool-result-only rows (also type="user") leave it untouched.
647        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        // Extract hook execution events from attachment records (v2.1.101 format).
659        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        // Extract hook summaries from system records (v2.1.119+ format).
684        // Subtype pattern: "<event>_hook_summary" → hook_event = "<Event>".
685        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        // Subsequent row for an already-seen message: union its content into
738        // the existing entry. Billing/model/timestamp on the entry stay as-is.
739        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        // Skip synthetic entries. Claude Code / the Agent SDK writes
752        // `model: "<synthetic>"` rows for internal routing (skill listings,
753        // queue ops, tool dispatch, etc.) — these never hit the LLM and have
754        // all-zero token counts. Counting them would inflate turn counts
755        // without representing real model activity.
756        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        // Cache-write tokens: prefer the larger of the legacy flat total and the
770        // 5m+1h split (guards partial/missing fields across Claude Code
771        // versions). Break out the 1-hour subset for tiered pricing.
772        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        // First row for this message: push placeholder entry with the content
793        // we've seen so far. Activity is classified at the end of the file
794        // against the merged content from all rows sharing this `message.id`.
795        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    // Classify each accumulated entry using the merged content from all rows
822    // that shared its `message.id`. Order doesn't matter — we mutate in place.
823    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
841/// Find the JSONL file for the current session by looking up Claude Code's
842/// active sessions directory, matching on project path.
843/// Falls back to finding the newest JSONL created after `spawned_at`.
844pub 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    // Strategy 1: Look up Claude Code's sessions/ directory for an active
858    // session matching our project path. This gives us the exact session ID.
859    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    // Strategy 2: Fall back to finding the earliest-created JSONL after spawn
870    // time. "Earliest" is critical: with two concurrent sessions for the same
871    // project, the older session must claim the first JSONL created after its
872    // spawn, not the latest one (which belongs to the newer session).
873    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    // Pick the earliest-created file after our spawn time
891    candidates.sort_by_key(|a| a.1);
892    candidates.into_iter().next().map(|(p, _)| p)
893}
894
895/// Search Claude Code's sessions/ directory for an active session matching
896/// the project path and started after our spawn time.
897fn 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    // Allow 10s grace period (Claude Code may start slightly after PTY spawn)
908    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                        // Pick the earliest session started after our spawn
925                        // threshold. With multiple concurrent sessions for the
926                        // same project, each has a different spawned_at: the
927                        // older session's threshold is lower, so it must claim
928                        // the first matching Claude session, not the latest one.
929                        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
941/// Normalize a path for comparison: lowercase, forward slashes, no trailing slash.
942fn normalize_path(p: &str) -> String {
943    p.replace('\\', "/")
944        .trim_end_matches('/')
945        .to_lowercase()
946}
947
948/// Reduce a list of `UsageEntry` for a single session into a `SessionUsage`.
949///
950/// Preserves existing semantics:
951/// - Primary model = the model used by the most messages (mode).
952/// - Context window % uses the LAST entry's total input (API resends the full
953///   conversation each turn; the last turn's input is the current usage).
954/// - `launched_model` (from launch options) is used for context window sizing
955///   because JSONL strips the `[1m]` suffix; fall back to mode when empty.
956pub 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    // Sum per-entry cost so mixed-model sessions, the 1-hour cache premium, the
993    // fast-mode multiplier, and web-search fees are all priced correctly — the
994    // single source of truth is `cost_for_entry`.
995    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    // Cache hit % reflects "what fraction of billable input-side tokens came
1017    // from cache". Denominator includes cache_write because writes are fresh
1018    // input being laid down for later reuse (billed at ~125% of input rate) —
1019    // they are NOT a hit. Only cache_read counts as reusing prior work.
1020    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        // Mirrors real Claude Code JSONL: each assistant message is written 2-3
1127        // times with identical usage as the stream progresses.
1128        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        // Headless `claude -p` writes JSONL with entrypoint="sdk-cli" on every
1145        // assistant row. Interactive Claude Code writes "cli". A row missing
1146        // the field (legacy/malformed) yields None.
1147        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        // 2026-04-14T08:27:38.895Z → 1776155258895 ms
1208        assert_eq!(entries[0].timestamp_ms, 1_776_155_258_895);
1209    }
1210
1211    #[test]
1212    fn parse_skips_synthetic_model_rows() {
1213        // Claude Code / Agent SDK writes `model: "<synthetic>"` for internal
1214        // routing (skill listings, queue ops). These must not count as turns.
1215        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            // JSONL strips the [1m] suffix
1268            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        // Claude's context window reflects the LAST turn's input, not the sum.
1290        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), // last: 160_005 / 200_000 = 80.0025%
1310        ];
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        // 3 fresh + 97 cache_read + 0 cache_write → 97 / 100 = 97%
1335        let u = reduce_session(&[e(3, 97, 0)], "", "sid").unwrap();
1336        assert!((u.cache_hit_pct - 97.0).abs() < 0.01);
1337
1338        // Cache writes count as NOT a hit: 10 fresh + 80 read + 10 write → 80%
1339        let u = reduce_session(&[e(10, 80, 10)], "", "sid").unwrap();
1340        assert!((u.cache_hit_pct - 80.0).abs() < 0.01);
1341
1342        // zero activity → 0 (not NaN)
1343        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        // Opus: $5/$25 input/output, $0.50 read, $6.25 write per 1M tokens
1377        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,   // $5
1382            output_tokens: 1_000_000,  // $25
1383            cache_read_tokens: 1_000_000, // $0.50
1384            cache_write_tokens: 1_000_000, // $6.25
1385            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    // ---------- content extraction ----------
1403
1404    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); // "Hello world" + "!"
1427    }
1428
1429    #[test]
1430    fn parse_handles_missing_content_gracefully() {
1431        // Most existing JSONL rows have no `content` field at all (older
1432        // Claude Code versions, or when we parse a row before content arrives).
1433        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        // Real-world flow: user asks to fix a bug, assistant runs Edit.
1445        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        // Sequence: user says "refactor the store", assistant runs a tool, the
1476        // injected tool_result row is also type="user", then another assistant
1477        // follow-up. The follow-up should still attribute to the original
1478        // "refactor" intent — tool_result rows must NOT overwrite last_user_text.
1479        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        // First assistant: Read only → Exploration
1495        assert_eq!(entries[0].activity, Activity::Exploration);
1496        // Second assistant: Edit after "refactor" intent → Refactor (not Coding),
1497        // proving the tool_result row didn't wipe last_user_text.
1498        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        // Some Claude Code versions write user content as an array even for
1524        // plain text. Ensure we pull the text out, not skip the row.
1525        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    // ---------- split-row content merge (Claude Code one-block-per-row) ----------
1534
1535    /// Helper: builds an assistant row that carries the same token usage
1536    /// numbers as the other rows for this `id` (matches what Claude Code
1537    /// actually writes — every row of a message repeats the final usage block).
1538    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        // Claude Code 2.1.x emits one JSONL row per content block of an
1547        // assistant message: a `thinking` row, an optional `text` row, then
1548        // one row per `tool_use`. All rows share the same `message.id` and
1549        // the same `usage` block. The parser must merge tool_uses across rows
1550        // rather than dropping every row after the first.
1551        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        // Billing fields come from the first row (they repeat identically, so
1594        // first == last in practice). Importantly, the merged content must
1595        // NOT cause tokens to multiply.
1596        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        // Bug pre-fix: first row is `thinking`, so `tool_uses` was empty and
1605        // activity classified as Conversation/General. After the fix the merged
1606        // content includes Edit, and a "fix the bug" user intent must route to
1607        // Debugging.
1608        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        // bash_cmds accumulate across rows too — pytest in a separate row
1630        // from the assistant's text must still classify as Testing.
1631        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    // ---------- hook extraction ----------
1649
1650    #[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        // hook_system_message should be ignored (no execution data)
1655        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        // Non-hook attachments (e.g. file-history-snapshot) should be skipped.
1680        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        // v2.1.119+ format: type=system, subtype=*_hook_summary
1700        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        // /goal-style stop hook: command is a multi-line goal statement.
1745        // The synthesized hook_name must NOT include the full body — otherwise
1746        // every distinct goal creates a unique bucket and breaks the Usage page.
1747        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        // Total label length is bounded: event prefix + ":" + max 64 chars + 1-char ellipsis.
1762        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        // Old v2.1.101 attachment format
1780        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        // New v2.1.119 system format
1782        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    // ---------- tiered cache / fast mode / web search pricing ----------
1793
1794    #[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        // Fable 5: $10 in / $50 out / $1 read / $12.50 5m-write / $20 1h-write per MTok.
1826        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        // Fast mode is Opus-only; an (unexpected) fast-tagged Fable turn must
1841        // not be multiplied — never overcount on a guess.
1842        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        // No `[1m]` suffix variant exists for Fable — 1M is the default.
1849        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        // Opus: 5m write $6.25/M, 1h write $10/M.
1857        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        // 400k 1h + 600k 5m = 0.4*10 + 0.6*6.25 = $7.75.
1862        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        // 3 web searches → $0.03, independent of tokens.
1869        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        // Opus 4.8 fast = 2x: 1M input $5 → $10.
1876        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        // Opus 4.6 fast = 6x → $30.
1881        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        // Web fee is flat even under fast: $10 token + $0.01 search.
1884        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        // Unknown future model → no premium (never overcount on a guess).
1898        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        // Fable 5.1: reads at 0.025× input ($0.25/MTok) instead of Fable 5's
1904        // 0.1× ($1/MTok). Every other rate is identical.
1905        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        // Sonnet 5: $2/$10 (standard price since 2026-09-01). Sonnet 4.6 and
1923        // earlier stay at $3/$15.
1924        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        // An id with no parseable family version must not pick up the newer,
1942        // cheaper tier — undercounting a real spend is worse than the default.
1943        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        // …except "fable", which is still recognised as the Fable family and
1948        // prices at the older (more expensive) cache-read rate.
1949        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        // 8-digit segment is a date, not a minor version.
1963        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        // Legacy ids carry the version before the family name, so the segment
1970        // after it is a date — not major version 20241022.
1971        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        // Nested split sums higher than the flat field → prefer the split.
1990        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        // Older rows: only the flat field → 1h = 0 (all treated as 5m), no speed/web.
2000        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        // 1M of 1h cache writes on opus = $10, not the old single-rate $6.25.
2012        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}