Skip to main content

agx_core/
timeline.rs

1use crate::session::{
2    AssistantContentItem, Entry, ToolResultContent, UserContent, UserContentItem,
3};
4use std::collections::HashMap;
5
6pub(crate) const LABEL_PREVIEW_WIDTH: usize = 60;
7pub(crate) const RESULT_PREVIEW_WIDTH: usize = 50;
8
9#[derive(Debug, Clone, Default, serde::Serialize)]
10pub struct Step {
11    pub label: String,
12    pub detail: String,
13    pub kind: StepKind,
14    pub tool_name: Option<String>,
15    pub timestamp_ms: Option<u64>,
16    pub duration_ms: Option<u64>,
17    /// Model name for this step, if known. Attached to the first step emitted
18    /// from each assistant message (see `attach_usage_to_first` below).
19    pub model: Option<String>,
20    /// Input tokens. Anthropic / OpenAI naming: one-time prompt tokens sent
21    /// to the model for this assistant response.
22    pub tokens_in: Option<u64>,
23    /// Output tokens: tokens the model generated in this assistant response.
24    pub tokens_out: Option<u64>,
25    /// Tokens read from the prompt cache (Anthropic). None for providers
26    /// that don't support or report cache reads.
27    pub cache_read: Option<u64>,
28    /// Tokens written to the prompt cache in this response (Anthropic).
29    pub cache_create: Option<u64>,
30    /// True when this step is the root of a conversation branch — i.e.
31    /// its originating entry shares a `parentUuid` with at least one
32    /// other entry in the same session. Only set by the Claude Code
33    /// parser today (the only format where `parentUuid` is a
34    /// first-class field); other parsers leave this `false`. Powers
35    /// the TUI fork list overlay and status-bar fork count (Phase 5.1).
36    #[serde(default)]
37    pub is_fork_root: bool,
38    /// The tool-call ID for `ToolUse` / `ToolResult` steps, pairing
39    /// them across the timeline. Populated by `tool_use_step` and
40    /// `tool_result_step`; every format parser passes this through
41    /// from its native ID field. Powers Phase 6 trajectory exports
42    /// (OpenAI fine-tuning format needs the explicit ID on each
43    /// tool_calls entry and on the matching tool message). `None`
44    /// for non-tool step kinds.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub tool_call_id: Option<String>,
47}
48
49impl Step {
50    /// USD cost for this step, computed from its token counters and model.
51    /// Returns `None` when the model is unknown or there are no tokens to
52    /// cost. Delegates to the pricing table in `crate::pricing`.
53    #[must_use]
54    pub fn cost_usd(&self) -> Option<f64> {
55        crate::pricing::cost_usd(
56            self.model.as_deref(),
57            self.tokens_in,
58            self.tokens_out,
59            self.cache_read,
60            self.cache_create,
61        )
62    }
63}
64
65/// Session-level totals for the `--summary` mode and future corpus
66/// analytics. Cost is `None` when no step had a known model; otherwise it
67/// sums `Step::cost_usd()` across steps that could be costed.
68#[derive(Debug, Default, serde::Serialize)]
69pub struct SessionTotals {
70    pub tokens_in: u64,
71    pub tokens_out: u64,
72    pub cache_read: u64,
73    pub cache_create: u64,
74    pub cost_usd: Option<f64>,
75    pub unique_models: Vec<String>,
76}
77
78impl SessionTotals {
79    #[must_use]
80    pub fn has_tokens(&self) -> bool {
81        self.tokens_in > 0 || self.tokens_out > 0 || self.cache_read > 0 || self.cache_create > 0
82    }
83}
84
85/// Aggregate token counters and cost across a set of steps. Returns zeros
86/// when nothing has usage data; callers should check `has_tokens()` before
87/// displaying.
88#[must_use]
89pub fn compute_session_totals(steps: &[Step]) -> SessionTotals {
90    let mut t = SessionTotals::default();
91    let mut models: Vec<String> = Vec::new();
92    let mut any_cost: Option<f64> = None;
93    for step in steps {
94        t.tokens_in += step.tokens_in.unwrap_or(0);
95        t.tokens_out += step.tokens_out.unwrap_or(0);
96        t.cache_read += step.cache_read.unwrap_or(0);
97        t.cache_create += step.cache_create.unwrap_or(0);
98        if let Some(m) = &step.model
99            && !models.iter().any(|existing| existing == m)
100        {
101            models.push(m.clone());
102        }
103        if let Some(c) = step.cost_usd() {
104            any_cost = Some(any_cost.unwrap_or(0.0) + c);
105        }
106    }
107    t.cost_usd = any_cost;
108    t.unique_models = models;
109    t
110}
111
112/// Normalized usage numbers any parser can produce. Each parser extracts its
113/// format-specific usage shape and lowers to this struct, which is then
114/// attached to the first step emitted from the corresponding assistant
115/// message via `attach_usage_to_first`.
116#[derive(Debug, Clone, Default)]
117pub(crate) struct Usage {
118    pub tokens_in: Option<u64>,
119    pub tokens_out: Option<u64>,
120    pub cache_read: Option<u64>,
121    pub cache_create: Option<u64>,
122}
123
124impl Usage {
125    pub fn is_empty(&self) -> bool {
126        self.tokens_in.is_none()
127            && self.tokens_out.is_none()
128            && self.cache_read.is_none()
129            && self.cache_create.is_none()
130    }
131}
132
133/// Attach model + usage to the first step at-or-after `start` in `steps`.
134/// Assistant messages in all formats carry a single usage counter for the
135/// whole message even though agx emits multiple steps (text + tool_uses).
136/// Attaching to the first step avoids double-counting when summing a corpus.
137pub(crate) fn attach_usage_to_first(
138    steps: &mut [Step],
139    start: usize,
140    model: Option<&str>,
141    usage: &Usage,
142) {
143    if let Some(step) = steps.get_mut(start) {
144        if let Some(m) = model {
145            step.model = Some(m.to_string());
146        }
147        if !usage.is_empty() {
148            step.tokens_in = usage.tokens_in;
149            step.tokens_out = usage.tokens_out;
150            step.cache_read = usage.cache_read;
151            step.cache_create = usage.cache_create;
152        }
153    }
154}
155
156/// Kind-of-step tag. Pattern-match on this to decide how to render.
157///
158/// `#[non_exhaustive]` signals that new variants will land when new
159/// step types surface (e.g. MCP resource reads per Phase 5.2).
160/// External consumers' match arms must include a wildcard; internal
161/// matches stay exhaustive. See `docs/stability.md`.
162#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
163#[serde(rename_all = "snake_case")]
164#[non_exhaustive]
165pub enum StepKind {
166    #[default]
167    UserText,
168    ToolResult,
169    AssistantText,
170    ToolUse,
171}
172
173#[derive(Debug, Default)]
174pub struct StepCounts {
175    pub user: usize,
176    pub assistant: usize,
177    pub tool_uses: usize,
178    pub tool_results: usize,
179}
180
181// Heuristic: does this step look like a failed tool call?
182// Only examines ToolResult steps. Extracts the "Result:" section of the
183// step detail and scans for substring indicators common across Claude Code,
184// Codex, and Gemini error outputs. Conservative — prefers false negatives
185// over false positives so users can trust the red marker.
186pub fn is_error_result(step: &Step) -> bool {
187    // Substring indicators that are safe to match without a word
188    // boundary — all distinctive enough that false positives are
189    // very rare. Exit-code matching lives below in
190    // `haystack_has_nonzero_exit_code` because "exit code 1" as a
191    // substring matches "exit code 127", "exit code 255", etc., and
192    // "exit code 10" is a clean completion code in some tools.
193    const INDICATORS: &[&str] = &[
194        "\"error\"",
195        "error:",
196        " failed",
197        "\nfailed",
198        "traceback",
199        "panic!",
200        "exception:",
201        "no such file",
202        "permission denied",
203        "command failed",
204    ];
205    if step.kind != StepKind::ToolResult {
206        return false;
207    }
208    let haystack = step
209        .detail
210        .split("\nResult:\n")
211        .nth(1)
212        .unwrap_or(&step.detail)
213        .to_lowercase();
214    INDICATORS.iter().any(|kw| haystack.contains(kw)) || haystack_has_nonzero_exit_code(&haystack)
215}
216
217/// Scan a lowercased haystack for `exit code <N>` / `process exited
218/// with code <N>` markers where `<N>` is a non-zero integer. Parses
219/// the digits cleanly instead of prefix-matching so `exit code 127`
220/// counts as an error (which it is) without `exit code 10` being
221/// confused for `exit code 1`.
222fn haystack_has_nonzero_exit_code(haystack: &str) -> bool {
223    const MARKERS: &[&str] = &["exit code ", "process exited with code "];
224    for marker in MARKERS {
225        let mut rest = haystack;
226        while let Some(idx) = rest.find(marker) {
227            let after = &rest[idx + marker.len()..];
228            // Greedy digit span — stops at any non-digit byte.
229            let digit_end = after
230                .as_bytes()
231                .iter()
232                .position(|b| !b.is_ascii_digit())
233                .unwrap_or(after.len());
234            if digit_end > 0
235                && let Ok(code) = after[..digit_end].parse::<u32>()
236                && code != 0
237            {
238                return true;
239            }
240            rest = &after[digit_end..];
241        }
242    }
243    false
244}
245
246pub fn count_from_steps(steps: &[Step]) -> StepCounts {
247    let mut c = StepCounts::default();
248    for step in steps {
249        match step.kind {
250            StepKind::UserText => c.user += 1,
251            StepKind::AssistantText => c.assistant += 1,
252            StepKind::ToolUse => c.tool_uses += 1,
253            StepKind::ToolResult => c.tool_results += 1,
254        }
255    }
256    c
257}
258
259#[derive(Debug, Clone, Default, serde::Serialize)]
260pub struct ToolStats {
261    pub name: String,
262    pub use_count: usize,
263    pub result_count: usize,
264    pub error_count: usize,
265}
266
267impl ToolStats {
268    pub fn error_rate(&self) -> Option<f64> {
269        if self.result_count == 0 {
270            None
271        } else {
272            #[allow(clippy::cast_precision_loss)]
273            Some(self.error_count as f64 / self.result_count as f64)
274        }
275    }
276}
277
278/// Aggregate per-tool statistics from a timeline. Returns a vector of
279/// `ToolStats` sorted by `use_count` descending.
280pub fn compute_tool_stats(steps: &[Step]) -> Vec<ToolStats> {
281    let mut map: HashMap<String, ToolStats> = HashMap::new();
282    for step in steps {
283        let Some(name) = &step.tool_name else {
284            continue;
285        };
286        let entry = map.entry(name.clone()).or_insert_with(|| ToolStats {
287            name: name.clone(),
288            ..ToolStats::default()
289        });
290        match step.kind {
291            StepKind::ToolUse => entry.use_count += 1,
292            StepKind::ToolResult => {
293                entry.result_count += 1;
294                if is_error_result(step) {
295                    entry.error_count += 1;
296                }
297            }
298            _ => {}
299        }
300    }
301    let mut stats: Vec<ToolStats> = map.into_values().collect();
302    stats.sort_by(|a, b| {
303        b.use_count
304            .cmp(&a.use_count)
305            .then_with(|| a.name.cmp(&b.name))
306    });
307    stats
308}
309
310#[derive(Debug, Clone)]
311struct ToolMeta {
312    name: String,
313    input_pretty: String,
314}
315
316/// Entries that share a `parentUuid` (including the implicit `None` root)
317/// form a fork. Returns the set of uuids that are fork roots — the
318/// entries whose originating step should be flagged `is_fork_root`.
319///
320/// Rules:
321/// - A parent with ≥2 children: every child is a fork root.
322/// - >1 root-level entries (parent_uuid == None): every root is a fork root.
323/// - A parent with exactly 1 child: normal linear continuation, not a fork.
324///
325/// Kept Claude-Code-private because only `session::Entry` carries
326/// `parentUuid`; other formats don't have the concept.
327fn collect_fork_root_uuids(entries: &[Entry]) -> std::collections::HashSet<&str> {
328    use std::collections::HashMap;
329    let mut children_by_parent: HashMap<Option<&str>, Vec<&str>> = HashMap::new();
330    for entry in entries {
331        let (uuid, parent) = match entry {
332            Entry::User(u) => (u.uuid.as_str(), u.parent_uuid.as_deref()),
333            Entry::Assistant(a) => (a.uuid.as_str(), a.parent_uuid.as_deref()),
334            Entry::Other => continue,
335        };
336        children_by_parent.entry(parent).or_default().push(uuid);
337    }
338    children_by_parent
339        .into_iter()
340        .filter(|(_, children)| children.len() > 1)
341        .flat_map(|(_, children)| children.into_iter())
342        .collect()
343}
344
345/// Count of fork-root steps in a timeline. Useful for status-bar hints
346/// ("[forks: N]") without re-walking the steps every frame.
347#[must_use]
348pub fn fork_root_count(steps: &[Step]) -> usize {
349    steps.iter().filter(|s| s.is_fork_root).count()
350}
351
352/// Indices (into `steps`) of every fork-root step, in the order they
353/// appear. The TUI's fork-list overlay walks this to let users
354/// jump-to-fork.
355#[must_use]
356pub fn fork_root_indices(steps: &[Step]) -> Vec<usize> {
357    steps
358        .iter()
359        .enumerate()
360        .filter(|(_, s)| s.is_fork_root)
361        .map(|(i, _)| i)
362        .collect()
363}
364
365pub fn build(entries: &[Entry]) -> Vec<Step> {
366    let tool_meta = collect_tool_meta(entries);
367    // Count children per parent_uuid to detect branch roots. An entry is
368    // a fork root when it shares its `parentUuid` with ≥1 other entry —
369    // i.e. the conversation forked at that parent. Also count
370    // root-level entries (parent_uuid = None): if there are >1, the
371    // file contains multiple independent conversation threads, each
372    // root is a fork root. Built in one pass so `build()` stays O(N).
373    let fork_uuids = collect_fork_root_uuids(entries);
374    let mut steps = Vec::new();
375    for entry in entries {
376        match entry {
377            Entry::User(u) => {
378                let ts = u.timestamp.as_deref().and_then(parse_iso_ms);
379                let is_fork = fork_uuids.contains(u.uuid.as_str());
380                let entry_first_idx = steps.len();
381                match &u.message.content {
382                    UserContent::Text(text) => {
383                        let mut step = user_text_step(text);
384                        step.timestamp_ms = ts;
385                        steps.push(step);
386                    }
387                    UserContent::Items(items) => {
388                        for item in items {
389                            match item {
390                                UserContentItem::Text { text } => {
391                                    let mut step = user_text_step(text);
392                                    step.timestamp_ms = ts;
393                                    steps.push(step);
394                                }
395                                UserContentItem::ToolResult {
396                                    tool_use_id,
397                                    content,
398                                } => {
399                                    let result_text = match content {
400                                        ToolResultContent::Text(s) => s.clone(),
401                                        ToolResultContent::Items(v) => pretty_json(v),
402                                    };
403                                    let meta = tool_meta.get(tool_use_id);
404                                    let mut step = tool_result_step(
405                                        tool_use_id,
406                                        &result_text,
407                                        meta.map(|m| m.name.as_str()),
408                                        meta.map(|m| m.input_pretty.as_str()),
409                                    );
410                                    step.timestamp_ms = ts;
411                                    steps.push(step);
412                                }
413                                UserContentItem::Other => {}
414                            }
415                        }
416                    }
417                }
418                if is_fork && let Some(first) = steps.get_mut(entry_first_idx) {
419                    first.is_fork_root = true;
420                }
421            }
422            Entry::Assistant(a) => {
423                let ts = a.timestamp.as_deref().and_then(parse_iso_ms);
424                let first_idx = steps.len();
425                let is_fork = fork_uuids.contains(a.uuid.as_str());
426                for item in &a.message.content {
427                    match item {
428                        AssistantContentItem::Text { text } => {
429                            let mut step = assistant_text_step(text);
430                            step.timestamp_ms = ts;
431                            steps.push(step);
432                        }
433                        AssistantContentItem::ToolUse { id, name, input } => {
434                            let input_pretty = pretty_json(input);
435                            let mut step = tool_use_step(id, name, &input_pretty);
436                            step.timestamp_ms = ts;
437                            steps.push(step);
438                        }
439                        AssistantContentItem::Other => {}
440                    }
441                }
442                if is_fork && let Some(first) = steps.get_mut(first_idx) {
443                    first.is_fork_root = true;
444                }
445                // If this assistant message produced at least one step, attach
446                // model + usage to the first of them. Format parsers across
447                // agx follow this same convention to avoid double-counting
448                // when summing a corpus.
449                if steps.len() > first_idx {
450                    let usage = a
451                        .message
452                        .usage
453                        .as_ref()
454                        .map(|u| Usage {
455                            tokens_in: u.input_tokens,
456                            tokens_out: u.output_tokens,
457                            cache_read: u.cache_read_input_tokens,
458                            cache_create: u.cache_creation_input_tokens,
459                        })
460                        .unwrap_or_default();
461                    attach_usage_to_first(
462                        &mut steps,
463                        first_idx,
464                        a.message.model.as_deref(),
465                        &usage,
466                    );
467                }
468            }
469            Entry::Other => {}
470        }
471    }
472    compute_durations(&mut steps);
473    steps
474}
475
476pub fn user_text_step(text: &str) -> Step {
477    Step {
478        label: format!("[user]   {}", truncate(text, LABEL_PREVIEW_WIDTH)),
479        detail: text.to_string(),
480        kind: StepKind::UserText,
481        ..Step::default()
482    }
483}
484
485pub fn assistant_text_step(text: &str) -> Step {
486    Step {
487        label: format!("[asst]   {}", truncate(text, LABEL_PREVIEW_WIDTH)),
488        detail: text.to_string(),
489        kind: StepKind::AssistantText,
490        ..Step::default()
491    }
492}
493
494pub fn tool_use_step(id: &str, name: &str, input_pretty: &str) -> Step {
495    Step {
496        label: format!("[tool]   {} ({})", name, short_id(id)),
497        detail: format!("Tool: {name}\nID: {id}\n\nInput:\n{input_pretty}"),
498        kind: StepKind::ToolUse,
499        tool_name: Some(name.to_string()),
500        tool_call_id: Some(id.to_string()),
501        ..Step::default()
502    }
503}
504
505pub fn tool_result_step(
506    id: &str,
507    result: &str,
508    tool_name: Option<&str>,
509    input_pretty: Option<&str>,
510) -> Step {
511    let display_name = tool_name.unwrap_or("(unknown)");
512    let input_section = input_pretty
513        .map(|p| format!("Input:\n{p}\n\n"))
514        .unwrap_or_default();
515    Step {
516        label: format!(
517            "[result] {} → {}",
518            display_name,
519            truncate(result, RESULT_PREVIEW_WIDTH)
520        ),
521        detail: format!("Tool: {display_name}\nID: {id}\n\n{input_section}Result:\n{result}"),
522        kind: StepKind::ToolResult,
523        tool_name: tool_name.map(str::to_string),
524        tool_call_id: Some(id.to_string()),
525        ..Step::default()
526    }
527}
528
529/// Compute sequential duration for each step (time since previous step).
530pub fn compute_durations(steps: &mut [Step]) {
531    for i in 1..steps.len() {
532        if let (Some(prev), Some(cur)) = (steps[i - 1].timestamp_ms, steps[i].timestamp_ms)
533            && cur >= prev
534        {
535            steps[i].duration_ms = Some(cur - prev);
536        }
537    }
538}
539
540/// Format a duration in ms to a compact human-readable string.
541#[allow(clippy::cast_precision_loss)]
542pub fn format_duration_ms(ms: u64) -> String {
543    if ms < 1_000 {
544        format!("{ms}ms")
545    } else if ms < 60_000 {
546        format!("{:.1}s", ms as f64 / 1_000.0)
547    } else {
548        format!("{:.1}min", ms as f64 / 60_000.0)
549    }
550}
551
552/// Parse ISO 8601 UTC timestamp to unix milliseconds. Handles
553/// `YYYY-MM-DDTHH:MM:SS[.fff][Z]` — the format all three CLIs produce.
554#[allow(
555    clippy::many_single_char_names,
556    clippy::cast_sign_loss,
557    clippy::cast_possible_wrap
558)]
559pub(crate) fn parse_iso_ms(s: &str) -> Option<u64> {
560    if s.len() < 19 {
561        return None;
562    }
563    let y: i64 = s.get(0..4)?.parse().ok()?;
564    let mo: u64 = s.get(5..7)?.parse().ok()?;
565    let d: u64 = s.get(8..10)?.parse().ok()?;
566    let h: u64 = s.get(11..13)?.parse().ok()?;
567    let mi: u64 = s.get(14..16)?.parse().ok()?;
568    let se: u64 = s.get(17..19)?.parse().ok()?;
569
570    // Howard Hinnant's days_from_civil
571    let (adj_y, adj_m) = if mo <= 2 {
572        (y - 1, mo + 9)
573    } else {
574        (y, mo - 3)
575    };
576    let era = if adj_y >= 0 { adj_y } else { adj_y - 399 } / 400;
577    let yoe = (adj_y - era * 400) as u64;
578    let doy = (153 * adj_m + 2) / 5 + d - 1;
579    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
580    let days = (era * 146_097 + doe as i64 - 719_468) as u64;
581
582    let secs = days * 86_400 + h * 3_600 + mi * 60 + se;
583
584    // Fractional ms after the seconds
585    let bytes = s.as_bytes();
586    let ms = if bytes.len() > 19 && bytes[19] == b'.' {
587        let end = bytes[20..]
588            .iter()
589            .position(|c| !c.is_ascii_digit())
590            .map_or(bytes.len(), |p| 20 + p);
591        let frac = s.get(20..end)?;
592        if frac.is_empty() {
593            0
594        } else {
595            let mut val: u64 = frac.parse().ok()?;
596            match frac.len() {
597                1 => val *= 100,
598                2 => val *= 10,
599                3 => {}
600                n => {
601                    val /= 10u64.pow(u32::try_from(n - 3).unwrap_or(0));
602                }
603            }
604            val
605        }
606    } else {
607        0
608    };
609
610    Some(secs * 1_000 + ms)
611}
612
613fn collect_tool_meta(entries: &[Entry]) -> HashMap<String, ToolMeta> {
614    let mut map = HashMap::new();
615    for entry in entries {
616        if let Entry::Assistant(a) = entry {
617            for item in &a.message.content {
618                if let AssistantContentItem::ToolUse { id, name, input } = item {
619                    map.insert(
620                        id.clone(),
621                        ToolMeta {
622                            name: name.clone(),
623                            input_pretty: pretty_json(input),
624                        },
625                    );
626                }
627            }
628        }
629    }
630    map
631}
632
633pub(crate) fn pretty_json<T: serde::Serialize>(value: &T) -> String {
634    serde_json::to_string_pretty(value).unwrap_or_default()
635}
636
637pub fn truncate(s: &str, n: usize) -> String {
638    let mut head = String::with_capacity(n);
639    let mut iter = s.chars().map(|c| if c == '\n' { ' ' } else { c });
640    for _ in 0..n {
641        match iter.next() {
642            Some(c) => head.push(c),
643            None => return head,
644        }
645    }
646    if iter.next().is_some() {
647        head.push('…');
648    }
649    head
650}
651
652pub(crate) fn short_id(id: &str) -> String {
653    // Char-based bounds — `&id[..11]` would panic if the id contains a
654    // multi-byte UTF-8 codepoint whose bytes straddle index 11 (and
655    // `tool_use_id` is attacker-controllable via session files).
656    // Preserves the original byte-length contract: strings of ≤12
657    // chars pass through unchanged; longer ones truncate to 11 chars
658    // plus an ellipsis.
659    if id.chars().count() <= 12 {
660        id.to_string()
661    } else {
662        let head: String = id.chars().take(11).collect();
663        format!("{head}…")
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use crate::session::{AssistantEntry, AssistantMessage, UserContent, UserEntry, UserMessage};
671
672    #[test]
673    fn builds_steps_from_user_and_assistant() {
674        let entries = vec![
675            Entry::User(UserEntry {
676                uuid: "u1".into(),
677                parent_uuid: None,
678                timestamp: None,
679                message: UserMessage {
680                    role: "user".into(),
681                    content: UserContent::Text("hello world".into()),
682                },
683            }),
684            Entry::Assistant(AssistantEntry {
685                uuid: "a1".into(),
686                parent_uuid: Some("u1".into()),
687                timestamp: None,
688                message: AssistantMessage {
689                    role: "assistant".into(),
690                    model: None,
691                    usage: None,
692                    content: vec![
693                        AssistantContentItem::Text {
694                            text: "thinking".into(),
695                        },
696                        AssistantContentItem::ToolUse {
697                            id: "toolu_abc".into(),
698                            name: "Read".into(),
699                            input: serde_json::json!({"file_path": "/x"}),
700                        },
701                    ],
702                },
703            }),
704        ];
705        let steps = build(&entries);
706        assert_eq!(steps.len(), 3);
707        assert_eq!(steps[0].kind, StepKind::UserText);
708        assert_eq!(steps[1].kind, StepKind::AssistantText);
709        assert_eq!(steps[2].kind, StepKind::ToolUse);
710        assert!(steps[2].detail.contains("Read"));
711        assert!(steps[2].detail.contains("/x"));
712    }
713
714    #[test]
715    fn usage_attaches_only_to_first_step_from_assistant_message() {
716        // Assistant message with text + tool_use. Usage applies to the whole
717        // message; only the first (text) step should carry the numbers so a
718        // corpus sum doesn't double-count.
719        use crate::session::{AssistantEntry, AssistantMessage, ClaudeUsage};
720        let entries = vec![Entry::Assistant(AssistantEntry {
721            uuid: "a1".into(),
722            parent_uuid: None,
723            timestamp: None,
724            message: AssistantMessage {
725                role: "assistant".into(),
726                model: Some("claude-opus-4-6".into()),
727                usage: Some(ClaudeUsage {
728                    input_tokens: Some(100),
729                    output_tokens: Some(50),
730                    cache_creation_input_tokens: Some(10),
731                    cache_read_input_tokens: Some(200),
732                }),
733                content: vec![
734                    AssistantContentItem::Text {
735                        text: "thinking".into(),
736                    },
737                    AssistantContentItem::ToolUse {
738                        id: "t1".into(),
739                        name: "Read".into(),
740                        input: serde_json::json!({"path": "/x"}),
741                    },
742                ],
743            },
744        })];
745        let steps = build(&entries);
746        assert_eq!(steps.len(), 2);
747        // First step carries everything
748        assert_eq!(steps[0].model.as_deref(), Some("claude-opus-4-6"));
749        assert_eq!(steps[0].tokens_in, Some(100));
750        assert_eq!(steps[0].tokens_out, Some(50));
751        assert_eq!(steps[0].cache_create, Some(10));
752        assert_eq!(steps[0].cache_read, Some(200));
753        // Second step carries nothing
754        assert_eq!(steps[1].model, None);
755        assert_eq!(steps[1].tokens_in, None);
756        assert_eq!(steps[1].tokens_out, None);
757    }
758
759    #[test]
760    fn missing_usage_leaves_all_steps_clean() {
761        use crate::session::{AssistantEntry, AssistantMessage};
762        let entries = vec![Entry::Assistant(AssistantEntry {
763            uuid: "a1".into(),
764            parent_uuid: None,
765            timestamp: None,
766            message: AssistantMessage {
767                role: "assistant".into(),
768                model: None,
769                usage: None,
770                content: vec![AssistantContentItem::Text { text: "ok".into() }],
771            },
772        })];
773        let steps = build(&entries);
774        assert_eq!(steps[0].tokens_in, None);
775        assert_eq!(steps[0].model, None);
776    }
777
778    #[test]
779    fn attach_usage_to_first_noop_on_empty_slice() {
780        let mut steps: Vec<Step> = Vec::new();
781        // Should not panic.
782        attach_usage_to_first(&mut steps, 0, Some("m"), &Usage::default());
783    }
784
785    // -------- Phase 5.1 fork detection --------
786
787    fn user_entry(uuid: &str, parent: Option<&str>, text: &str) -> Entry {
788        Entry::User(UserEntry {
789            uuid: uuid.into(),
790            parent_uuid: parent.map(str::to_string),
791            timestamp: None,
792            message: UserMessage {
793                role: "user".into(),
794                content: UserContent::Text(text.into()),
795            },
796        })
797    }
798
799    fn asst_entry(uuid: &str, parent: Option<&str>, text: &str) -> Entry {
800        Entry::Assistant(AssistantEntry {
801            uuid: uuid.into(),
802            parent_uuid: parent.map(str::to_string),
803            timestamp: None,
804            message: AssistantMessage {
805                role: "assistant".into(),
806                model: None,
807                usage: None,
808                content: vec![AssistantContentItem::Text { text: text.into() }],
809            },
810        })
811    }
812
813    #[test]
814    fn linear_session_has_no_fork_roots() {
815        let entries = vec![
816            user_entry("u1", None, "hi"),
817            asst_entry("a1", Some("u1"), "hello"),
818            user_entry("u2", Some("a1"), "and"),
819            asst_entry("a2", Some("u2"), "ok"),
820        ];
821        let steps = build(&entries);
822        assert_eq!(fork_root_count(&steps), 0);
823        assert!(fork_root_indices(&steps).is_empty());
824    }
825
826    #[test]
827    fn two_siblings_of_same_parent_are_fork_roots() {
828        // u1 has two children: a1 and a2 — both are fork roots. The
829        // assistant's first emitted step carries the marker.
830        let entries = vec![
831            user_entry("u1", None, "prompt"),
832            asst_entry("a1", Some("u1"), "first reply"),
833            asst_entry("a2", Some("u1"), "second reply"),
834        ];
835        let steps = build(&entries);
836        assert_eq!(fork_root_count(&steps), 2);
837        assert!(steps[1].is_fork_root);
838        assert!(steps[2].is_fork_root);
839        assert!(!steps[0].is_fork_root);
840    }
841
842    #[test]
843    fn multiple_root_entries_are_all_fork_roots() {
844        // Two independent conversation roots in one file — every root
845        // is a fork root because parent_uuid=None shows up >1 time.
846        let entries = vec![
847            user_entry("u1", None, "thread 1"),
848            user_entry("u2", None, "thread 2"),
849        ];
850        let steps = build(&entries);
851        assert_eq!(fork_root_count(&steps), 2);
852        assert_eq!(fork_root_indices(&steps), vec![0, 1]);
853    }
854
855    #[test]
856    fn single_root_entry_is_not_a_fork() {
857        let entries = vec![user_entry("u1", None, "only thread")];
858        let steps = build(&entries);
859        assert_eq!(fork_root_count(&steps), 0);
860    }
861
862    #[test]
863    fn fork_root_marker_only_on_first_emitted_step() {
864        // A forked assistant message with two content items emits two
865        // steps — the marker should land on the first one only.
866        use crate::session::{AssistantEntry, AssistantMessage};
867        let entries = vec![
868            user_entry("u1", None, "prompt"),
869            Entry::Assistant(AssistantEntry {
870                uuid: "a1".into(),
871                parent_uuid: Some("u1".into()),
872                timestamp: None,
873                message: AssistantMessage {
874                    role: "assistant".into(),
875                    model: None,
876                    usage: None,
877                    content: vec![
878                        AssistantContentItem::Text {
879                            text: "thinking".into(),
880                        },
881                        AssistantContentItem::ToolUse {
882                            id: "t1".into(),
883                            name: "Read".into(),
884                            input: serde_json::json!({}),
885                        },
886                    ],
887                },
888            }),
889            asst_entry("a2", Some("u1"), "alt reply"),
890        ];
891        let steps = build(&entries);
892        // a1 emits 2 steps (text + tool_use), a2 emits 1. Forks are a1 and a2.
893        assert_eq!(steps.len(), 4);
894        assert!(steps[1].is_fork_root, "first step from a1 should be marked");
895        assert!(
896            !steps[2].is_fork_root,
897            "subsequent step from same entry should not be marked"
898        );
899        assert!(steps[3].is_fork_root);
900    }
901
902    #[test]
903    fn usage_is_empty_detects_all_none() {
904        assert!(Usage::default().is_empty());
905        let u = Usage {
906            tokens_in: Some(1),
907            ..Usage::default()
908        };
909        assert!(!u.is_empty());
910    }
911
912    #[test]
913    fn step_cost_usd_delegates_to_pricing_table() {
914        let mut step = assistant_text_step("hi");
915        step.model = Some("claude-opus-4-6".into());
916        step.tokens_in = Some(1_000_000);
917        step.tokens_out = Some(1_000_000);
918        let c = step.cost_usd().unwrap();
919        assert!((c - 90.0).abs() < 1e-6);
920    }
921
922    #[test]
923    fn step_cost_usd_none_when_no_model() {
924        let mut step = assistant_text_step("hi");
925        step.tokens_in = Some(100);
926        assert_eq!(step.cost_usd(), None);
927    }
928
929    #[test]
930    fn step_cost_usd_none_when_no_tokens() {
931        let mut step = assistant_text_step("hi");
932        step.model = Some("claude-opus-4-6".into());
933        assert_eq!(step.cost_usd(), None);
934    }
935
936    #[test]
937    fn session_totals_sums_tokens_and_costs_across_steps() {
938        let mut s1 = assistant_text_step("a");
939        s1.model = Some("claude-opus-4-6".into());
940        s1.tokens_in = Some(100);
941        s1.tokens_out = Some(50);
942        let mut s2 = assistant_text_step("b");
943        s2.model = Some("claude-opus-4-6".into());
944        s2.tokens_in = Some(200);
945        s2.tokens_out = Some(75);
946        let t = compute_session_totals(&[s1, s2]);
947        assert_eq!(t.tokens_in, 300);
948        assert_eq!(t.tokens_out, 125);
949        assert_eq!(t.unique_models, vec!["claude-opus-4-6"]);
950        assert!(t.cost_usd.is_some());
951    }
952
953    #[test]
954    fn session_totals_dedupes_unique_models() {
955        let mut s1 = assistant_text_step("a");
956        s1.model = Some("claude-opus-4-6".into());
957        let mut s2 = assistant_text_step("b");
958        s2.model = Some("claude-sonnet-4-6".into());
959        let mut s3 = assistant_text_step("c");
960        s3.model = Some("claude-opus-4-6".into());
961        let t = compute_session_totals(&[s1, s2, s3]);
962        assert_eq!(t.unique_models.len(), 2);
963        assert!(t.unique_models.contains(&"claude-opus-4-6".to_string()));
964        assert!(t.unique_models.contains(&"claude-sonnet-4-6".to_string()));
965    }
966
967    #[test]
968    fn session_totals_cost_none_when_no_known_models() {
969        let mut s = assistant_text_step("a");
970        s.model = Some("unknown-model".into());
971        s.tokens_in = Some(100);
972        let t = compute_session_totals(&[s]);
973        assert_eq!(t.tokens_in, 100);
974        assert_eq!(t.cost_usd, None);
975    }
976
977    #[test]
978    fn session_totals_has_tokens_false_on_empty() {
979        let t = SessionTotals::default();
980        assert!(!t.has_tokens());
981    }
982
983    #[test]
984    fn session_totals_has_tokens_true_when_any_counter_set() {
985        let t = SessionTotals {
986            tokens_in: 1,
987            ..SessionTotals::default()
988        };
989        assert!(t.has_tokens());
990    }
991
992    #[test]
993    fn tool_result_label_uses_tool_name_from_paired_use() {
994        let entries = vec![
995            Entry::Assistant(AssistantEntry {
996                uuid: "a1".into(),
997                parent_uuid: None,
998                timestamp: None,
999                message: AssistantMessage {
1000                    role: "assistant".into(),
1001                    model: None,
1002                    usage: None,
1003                    content: vec![AssistantContentItem::ToolUse {
1004                        id: "toolu_abc".into(),
1005                        name: "Bash".into(),
1006                        input: serde_json::json!({"command": "ls"}),
1007                    }],
1008                },
1009            }),
1010            Entry::User(UserEntry {
1011                uuid: "u2".into(),
1012                parent_uuid: Some("a1".into()),
1013                timestamp: None,
1014                message: UserMessage {
1015                    role: "user".into(),
1016                    content: UserContent::Items(vec![UserContentItem::ToolResult {
1017                        tool_use_id: "toolu_abc".into(),
1018                        content: ToolResultContent::Text("file1\nfile2".into()),
1019                    }]),
1020                },
1021            }),
1022        ];
1023        let steps = build(&entries);
1024        assert_eq!(steps.len(), 2);
1025        assert_eq!(steps[1].kind, StepKind::ToolResult);
1026        assert!(
1027            steps[1].label.contains("Bash"),
1028            "expected label to include tool name, got: {}",
1029            steps[1].label
1030        );
1031        assert!(steps[1].detail.contains("Tool: Bash"));
1032        assert!(steps[1].detail.contains("Input:"));
1033        assert!(steps[1].detail.contains("\"command\""));
1034        assert!(steps[1].detail.contains("Result:"));
1035        assert!(steps[1].detail.contains("file1"));
1036    }
1037
1038    #[test]
1039    fn tool_result_falls_back_when_no_paired_use() {
1040        let entries = vec![Entry::User(UserEntry {
1041            uuid: "u1".into(),
1042            parent_uuid: None,
1043            timestamp: None,
1044            message: UserMessage {
1045                role: "user".into(),
1046                content: UserContent::Items(vec![UserContentItem::ToolResult {
1047                    tool_use_id: "toolu_orphan".into(),
1048                    content: ToolResultContent::Text("output".into()),
1049                }]),
1050            },
1051        })];
1052        let steps = build(&entries);
1053        assert_eq!(steps.len(), 1);
1054        assert!(steps[0].label.contains("(unknown)"));
1055        assert!(steps[0].detail.contains("Tool: (unknown)"));
1056        assert!(!steps[0].detail.contains("Input:"));
1057        assert!(steps[0].detail.contains("Result:"));
1058    }
1059
1060    #[test]
1061    fn count_from_steps_works() {
1062        let steps = vec![
1063            user_text_step("hi"),
1064            assistant_text_step("hello"),
1065            tool_use_step("id1", "Read", "{}"),
1066            tool_result_step("id1", "output", Some("Read"), Some("{}")),
1067            tool_use_step("id2", "Bash", "{}"),
1068        ];
1069        let c = count_from_steps(&steps);
1070        assert_eq!(c.user, 1);
1071        assert_eq!(c.assistant, 1);
1072        assert_eq!(c.tool_uses, 2);
1073        assert_eq!(c.tool_results, 1);
1074    }
1075
1076    #[test]
1077    fn truncate_handles_short_strings() {
1078        assert_eq!(truncate("hi", 10), "hi");
1079    }
1080
1081    #[test]
1082    fn truncate_handles_long_strings() {
1083        let s = "a".repeat(20);
1084        assert_eq!(truncate(&s, 5), "aaaaa…");
1085    }
1086
1087    #[test]
1088    fn truncate_replaces_newlines() {
1089        assert_eq!(truncate("a\nb\nc", 10), "a b c");
1090    }
1091
1092    #[test]
1093    fn truncate_handles_exact_length() {
1094        assert_eq!(truncate("abcde", 5), "abcde");
1095    }
1096
1097    #[test]
1098    fn truncate_handles_unicode() {
1099        assert_eq!(truncate("héllo", 3), "hél…");
1100        assert_eq!(truncate("héllo世界", 5), "héllo…");
1101        assert_eq!(truncate("héllo世界", 6), "héllo世…");
1102        assert_eq!(truncate("héllo世界", 7), "héllo世界");
1103    }
1104
1105    #[test]
1106    fn short_id_passes_short_strings_through() {
1107        assert_eq!(short_id(""), "");
1108        assert_eq!(short_id("abc"), "abc");
1109        assert_eq!(short_id("toolu_abcde"), "toolu_abcde");
1110    }
1111
1112    #[test]
1113    fn short_id_truncates_long_strings_at_eleven() {
1114        assert_eq!(short_id("toolu_0123456789xyz"), "toolu_01234…");
1115        assert_eq!(short_id("toolu_abcdefghijkl"), "toolu_abcde…");
1116    }
1117
1118    #[test]
1119    fn short_id_handles_exact_twelve_boundary() {
1120        assert_eq!(short_id("123456789012"), "123456789012");
1121        assert_eq!(short_id("1234567890123"), "12345678901…");
1122    }
1123
1124    #[test]
1125    fn short_id_does_not_panic_on_multibyte_utf8_near_boundary() {
1126        // The old `&id[..11]` slice would panic on an input where a
1127        // 4-byte emoji begins at byte index 11 (byte index falls
1128        // inside the codepoint). 11 ASCII chars + 😀 + "xyz" = 15
1129        // chars / 18 bytes, so it's long enough to force truncation.
1130        let id = "abcdefghijk😀xyz";
1131        let out = short_id(id);
1132        // Takes first 11 chars cleanly and appends the ellipsis.
1133        assert_eq!(out, "abcdefghijk…");
1134    }
1135
1136    fn result_step_with_body(body: &str) -> Step {
1137        tool_result_step("t1", body, Some("Bash"), Some("{}"))
1138    }
1139
1140    #[test]
1141    fn is_error_result_detects_error_keyword() {
1142        let step = result_step_with_body("error: file not found");
1143        assert!(is_error_result(&step));
1144    }
1145
1146    #[test]
1147    fn is_error_result_detects_failed_word() {
1148        let step = result_step_with_body("Command failed with exit code 1");
1149        assert!(is_error_result(&step));
1150    }
1151
1152    #[test]
1153    fn is_error_result_detects_traceback() {
1154        let step = result_step_with_body("Traceback (most recent call last):\n  ...");
1155        assert!(is_error_result(&step));
1156    }
1157
1158    #[test]
1159    fn is_error_result_detects_no_such_file() {
1160        let step = result_step_with_body("ls: /nonexistent: No such file or directory");
1161        assert!(is_error_result(&step));
1162    }
1163
1164    #[test]
1165    fn is_error_result_detects_exit_code_nonzero() {
1166        let step = result_step_with_body("Process exited with code 127");
1167        // 127 is parsed as a non-zero integer by
1168        // `haystack_has_nonzero_exit_code`.
1169        assert!(is_error_result(&step));
1170    }
1171
1172    #[test]
1173    fn is_error_result_ignores_exit_code_zero() {
1174        // Exit code 0 is a clean completion — not an error. Previous
1175        // substring-based matching didn't have this case; the
1176        // integer parser rejects 0 cleanly.
1177        let step = result_step_with_body("Process exited with code 0\nAll tests passed.");
1178        assert!(!is_error_result(&step));
1179    }
1180
1181    #[test]
1182    fn is_error_result_detects_two_digit_exit_codes() {
1183        // Earlier prefix matching treated `exit code 10` as a
1184        // substring of `exit code 1`. With the integer parser, 10 is
1185        // its own non-zero value — still an error (consistent
1186        // behavior), but not via prefix coincidence.
1187        let step = result_step_with_body("Bash exited with exit code 10");
1188        assert!(is_error_result(&step));
1189    }
1190
1191    #[test]
1192    fn is_error_result_finds_embedded_exit_code_after_verbose_output() {
1193        // Regression: the scan must find the marker even when it's
1194        // preceded by arbitrary non-error output, not only at the
1195        // start of the haystack.
1196        let step = result_step_with_body(
1197            "running 3 tests\ntest a ... ok\nok\nshell exited with exit code 42",
1198        );
1199        assert!(is_error_result(&step));
1200    }
1201
1202    #[test]
1203    fn is_error_result_detects_json_error_field() {
1204        let step = result_step_with_body("{\"error\": \"bad request\"}");
1205        assert!(is_error_result(&step));
1206    }
1207
1208    #[test]
1209    fn is_error_result_returns_false_for_clean_output() {
1210        let step = result_step_with_body("[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]");
1211        assert!(!is_error_result(&step));
1212    }
1213
1214    #[test]
1215    fn is_error_result_returns_false_for_non_tool_result() {
1216        let step = user_text_step("error in my user message");
1217        assert!(!is_error_result(&step));
1218    }
1219
1220    #[test]
1221    fn is_error_result_only_checks_result_section() {
1222        // Input section mentions "error" but Result section is clean.
1223        let step = tool_result_step(
1224            "t1",
1225            "all good",
1226            Some("Bash"),
1227            Some("{\"command\": \"grep error\"}"),
1228        );
1229        assert!(!is_error_result(&step));
1230    }
1231
1232    #[test]
1233    fn tool_use_step_records_tool_name() {
1234        let s = tool_use_step("t1", "Read", "{}");
1235        assert_eq!(s.tool_name.as_deref(), Some("Read"));
1236    }
1237
1238    #[test]
1239    fn tool_result_step_records_tool_name() {
1240        let s = tool_result_step("t1", "ok", Some("Bash"), Some("{}"));
1241        assert_eq!(s.tool_name.as_deref(), Some("Bash"));
1242    }
1243
1244    #[test]
1245    fn tool_result_step_tool_name_none_for_orphan() {
1246        let s = tool_result_step("t1", "ok", None, None);
1247        assert_eq!(s.tool_name, None);
1248    }
1249
1250    #[test]
1251    fn text_steps_have_no_tool_name() {
1252        assert_eq!(user_text_step("hi").tool_name, None);
1253        assert_eq!(assistant_text_step("ok").tool_name, None);
1254    }
1255
1256    #[test]
1257    fn compute_tool_stats_groups_by_tool_name() {
1258        let steps = vec![
1259            tool_use_step("t1", "Read", "{}"),
1260            tool_result_step("t1", "content", Some("Read"), Some("{}")),
1261            tool_use_step("t2", "Read", "{}"),
1262            tool_result_step("t2", "content2", Some("Read"), Some("{}")),
1263            tool_use_step("t3", "Bash", "{}"),
1264            tool_result_step("t3", "output", Some("Bash"), Some("{}")),
1265        ];
1266        let stats = compute_tool_stats(&steps);
1267        assert_eq!(stats.len(), 2);
1268        // Read should come first (2 uses vs 1)
1269        assert_eq!(stats[0].name, "Read");
1270        assert_eq!(stats[0].use_count, 2);
1271        assert_eq!(stats[0].result_count, 2);
1272        assert_eq!(stats[0].error_count, 0);
1273        assert_eq!(stats[1].name, "Bash");
1274        assert_eq!(stats[1].use_count, 1);
1275    }
1276
1277    #[test]
1278    fn compute_tool_stats_counts_errors() {
1279        let steps = vec![
1280            tool_use_step("t1", "Bash", "{}"),
1281            tool_result_step("t1", "error: command failed", Some("Bash"), Some("{}")),
1282            tool_use_step("t2", "Bash", "{}"),
1283            tool_result_step("t2", "success", Some("Bash"), Some("{}")),
1284        ];
1285        let stats = compute_tool_stats(&steps);
1286        assert_eq!(stats.len(), 1);
1287        assert_eq!(stats[0].use_count, 2);
1288        assert_eq!(stats[0].error_count, 1);
1289        assert_eq!(stats[0].error_rate(), Some(0.5));
1290    }
1291
1292    #[test]
1293    fn compute_tool_stats_sorts_by_use_count_descending() {
1294        let steps = vec![
1295            tool_use_step("t1", "Apple", "{}"),
1296            tool_use_step("t2", "Banana", "{}"),
1297            tool_use_step("t3", "Banana", "{}"),
1298            tool_use_step("t4", "Banana", "{}"),
1299            tool_use_step("t5", "Cherry", "{}"),
1300            tool_use_step("t6", "Cherry", "{}"),
1301        ];
1302        let stats = compute_tool_stats(&steps);
1303        assert_eq!(stats.len(), 3);
1304        assert_eq!(stats[0].name, "Banana"); // 3 uses
1305        assert_eq!(stats[1].name, "Cherry"); // 2 uses
1306        assert_eq!(stats[2].name, "Apple"); // 1 use
1307    }
1308
1309    #[test]
1310    fn compute_tool_stats_empty_for_text_only() {
1311        let steps = vec![user_text_step("hi"), assistant_text_step("hello")];
1312        let stats = compute_tool_stats(&steps);
1313        assert!(stats.is_empty());
1314    }
1315
1316    #[test]
1317    fn tool_stats_error_rate_none_when_no_results() {
1318        let stats = ToolStats {
1319            name: "X".into(),
1320            use_count: 1,
1321            result_count: 0,
1322            error_count: 0,
1323        };
1324        assert_eq!(stats.error_rate(), None);
1325    }
1326}