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